常见的并发原因和解决方法
- 死锁:当两个或多个线程互相持有对方需要的锁,而无法释放自己的锁时,就会发生死锁。解决死锁的方法有:避免嵌套锁、按顺序获取锁、使用定时锁、检测并恢复死锁等。
- 活锁:当两个或多个线程不断地改变自己的状态以响应对方的状态,而无法继续执行时,就会发生活锁。解决活锁的方法有:引入随机性、增加重试间隔、限制重试次数等。
- 饥饿:当一个或多个线程因为优先级低或者等待资源被长时间占用而无法得到执行机会时,就会发生饥饿。解决饥饿的方法有:使用公平的调度策略、避免设置不同的线程优先级、减少长时间占用资源的操作等。
- 内存可见性:当一个线程修改了共享变量,而其他线程不能及时看到这个修改时,就会发生内存可见性问题。解决内存可见性问题的方法有:使用 volatile 关键字、使用 synchronized 关键字、使用原子类等。
- 竞态条件:当一个线程执行的结果依赖于另一个线程执行的顺序或者时间点时,就会发生竞态条件问题。解决竞态条件问题的方法有:使用同步机制(synchronized 关键字、Lock 接口等)、使用原子操作(Atomic 类、CAS 算法等)。
解决死锁问题-按顺序获取锁
Java使用按顺序获取锁解决死锁问题的思想是保证所有线程以相同的顺序获取锁,从而打破环路等待条件。例如,如果有两个线程分别需要获取锁A和锁B,那么它们都应该先获取锁A,再获取锁B,而不是交叉获取。
//定义两个对象锁
private static Object lockA = new Object();
private static Object lockB = new Object();
//线程1
public void thread1() {
synchronized (lockA) { //先获取锁A
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) { //再获取锁B
System.out.println("thread1");
}
}
}
//线程2
public void thread2() {
synchronized (lockA) { //先获取锁A
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) { //再获取锁B
System.out.println("thread2");
}
}
}
解决死锁问题-使用定时锁
JAVA并发编程中,定时锁是一种可以在指定时间内尝试获取锁的方法。它可以使用java.util.concurrent.locks包中的Lock接口和ReentrantLock类来实现。定时锁的优点是可以避免线程长时间阻塞等待锁,提高系统的响应性和效率。
- 使用定时锁的步骤如下:
- 创建一个ReentrantLock对象,并设置为公平或非公平模式。
- 在需要同步的代码块前,调用lock.tryLock(long time, TimeUnit unit)方法,传入一个超时时间和时间单位,该方法会返回一个布尔值,表示是否成功获取到锁。
- 如果成功获取到锁,则执行同步代码块,并在最后调用lock.unlock()方法释放锁。
- 如果没有成功获取到锁,则根据业务逻辑进行处理,比如重试、抛出异常、返回错误信息等。
- 假设有两个线程A和B,分别要执行两个同步方法methodA和methodB,这两个方法都需要获取一个ReentrantLock对象lock。代码如下:java运行结果如下:
public class LockExample { private static final ReentrantLock lock = new ReentrantLock(); public static void methodA() { try { // 尝试在5秒内获取锁 if (lock.tryLock(5, TimeUnit.SECONDS)) { // 获取到锁,执行业务逻辑 System.out.println(Thread.currentThread().getName() + "执行methodA"); Thread.sleep(6000); // 模拟耗时操作 } else { // 没有获取到锁,抛出异常 throw new RuntimeException(Thread.currentThread().getName() + "没有获取到锁"); } } catch (InterruptedException e) { e.printStackTrace(); } finally { // 释放锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } public static void methodB() { try { // 尝试在5秒内获取锁 if (lock.tryLock(5, TimeUnit.SECONDS)) { // 获取到锁,执行业务逻辑 System.out.println(Thread.currentThread().getName() + "执行methodB"); Thread.sleep(4000); // 模拟耗时操作 } else { // 没有获取到锁,抛出异常 throw new RuntimeException(Thread.currentThread().getName() + "没有获取到锁"); } } catch (InterruptedException e) { e.printStackTrace(); } finally { // 释放锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } public static void main(String[] args) throws InterruptedException { Thread threadA = new Thread(() -> methodA(), "线程A"); Thread threadB = new Thread(() -> methodB(), "线程B"); threadA.start(); threadB.start(); } }
console可以看出,当线程A先获得了锁,并执行了6秒的耗时操作后,线程B尝试在5秒内获得锁,但是失败了,所以抛出了异常。线程A执行methodA 线程B没有获取到锁Exception in thread "线程B" java.lang.RuntimeException: 线程B没有获取到锁 at LockExample.methodB(LockExample.java:38) at java.base/java.lang.Thread.run(Thread.java:832)
解决死锁问题-如何检测并恢复死锁
- 检测死锁:可以使用进程-资源分配图来表示系统中的进程和资源的分配情况,如果图中存在环路,说明系统中存在死锁。也可以使用矩阵算法来检测死锁,通过计算每个进程还需要的资源数量和系统可用的资源数量,判断是否有足够的资源满足某个进程,如果没有,则说明系统中存在死锁。
- 恢复死锁:可以使用以下几种方法来解除死锁:
- 资源剥夺法:将一些死锁进程暂时挂起,并且抢占它的资源,并将这些资源分配给其他的死锁进程。要注意的是应该防止被挂起的进程长时间得不到资源而处于饥饿状态。
- 撤销进程法:强制终止一个或多个死锁进程,并释放它们占用的资源。要注意的是应该选择代价最小的进程进行撤销,比如优先级低、运行时间长、占用资源多等。
- 回滚法:将一个或多个死锁进程回滚到某个检查点,并重新执行。要注意的是应该选择回滚代价最小的检查点,比如距离当前状态最近、影响其他进程最少等。
矩阵算法 检测死锁
- 矩阵算法是一种用于检测系统中是否发生死锁的方法。它主要使用以下几个矩阵来表示系统的状态:
- E:表示系统中可用的资源数量,是一个1×m的向量,其中m是资源类型的数量。
- A:表示系统已经分配给各个进程的资源数量,是一个n×m的矩阵,其中n是进程的数量。
- C:表示各个进程还需要的资源数量,也是一个n×m的矩阵。
- W:表示系统当前可分配给进程的资源数量,是一个1×m的向量,初始时W=E。
- 矩阵算法的步骤如下:
- 从C中找出一行Ci,使得Ci≤W,如果找不到这样一行,则说明系统中存在死锁。
- 假设找到了这样一行Ci,则将该进程标记为完成,并将其占用的资源释放给系统,即W=W+Ai。
- 重复上述两步,直到所有进程都被标记为完成或者发现死锁。
解决活锁问题-引入随机性
Java使用引入随机性解决活锁问题的思想是在重试机制中等待一个随机的时间,从而降低同时冲突的概率。例如,如果有两个线程分别需要执行一个任务,但是每次执行时都会检测到对方也在执行,导致互相谦让而无法完成任务,那么它们可以在每次谦让后等待一个随机的时间再重试。
//定义一个任务
public class Task implements Runnable {
private String name;
private Random random = new Random();
public Task(String name) {
this.name = name;
}
@Override
public void run() {
while (true) {
//检测是否有其他线程正在执行任务
if (Thread.interrupted()) {
System.out.println(name + "发现对方正在执行任务,谦让一下");
//等待一个随机的时间再重试
try {
Thread.sleep(random.nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
System.out.println(name + "开始执行任务");
//中断另一个线程
Thread.currentThread().getThreadGroup().interrupt();
}
}
}
}
//创建两个线程分别执行任务
public class Test {
public static void main(String[] args) {
ThreadGroup group = new ThreadGroup("group");
Thread t1 = new Thread(group, new Task("t1"));
Thread t2 = new Thread(group, new Task("t2"));
t1.start();
t2.start();
}
}
方案缺点:
- 这种方法的缺点是可能会增加执行任务的时间,因为每次谦让后都要等待一个随机的时间。另外,这种方法也不能保证一定能避免活锁,只是降低了发生活锁的概率。
解决活锁问题-增加重试间隔
Java使用增加重试间隔解决活锁问题的思想是在重试机制中每次失败后延长等待的时间,从而降低同时冲突的概率。例如,如果有一个接口调用失败了,可以先等待3秒再重试,如果还失败了,可以再等待6秒再重试,以此类推。
//定义一个接口调用方法
public class Service {
private Random random = new Random();
public void call() throws Exception {
//模拟一定概率的失败
if (random.nextInt(10) < 8) {
throw new Exception("调用失败");
} else {
System.out.println("调用成功");
}
}
}
//定义一个重试机制类
public class Retryer {
private int maxAttempts; //最大重试次数
private long interval; //初始间隔时间
private double multiplier; //间隔时间增长因子
public Retryer(int maxAttempts, long interval, double multiplier) {
this.maxAttempts = maxAttempts;
this.interval = interval;
this.multiplier = multiplier;
}
//执行一个任务,并在失败时进行重试
public void execute(Runnable task) throws Exception {
int attempts = 0; //当前尝试次数
long waitTime = interval; //当前等待时间
while (true) {
try {
task.run(); //执行任务
break; //成功则退出循环
} catch (Exception e) { //捕获异常
attempts++; //尝试次数加一
if (attempts >= maxAttempts) { //达到最大尝试次数则抛出异常
throw new Exception("超过最大重试次数", e);
} else { //否则等待一段时间再重试,并更新等待时间
System.out.println("第" + attempts + "次调用失败,等待" + waitTime + "毫秒后重试");
Thread.sleep(waitTime);
waitTime *= multiplier;
}
}
}
}
}
//创建一个服务对象和一个重试器对象,并执行任务
public class Test {
public static void main(String[] args) throws Exception {
Service service = new Service();
Retryer retryer = new Retryer(5, 3000, 2);
retryer.execute(() -> service.call());
}
}
方案优点:
- 这种方法的优点是可以提高业务的可用性、容错性和一致性,可以有效地避免活锁的发生,可以适应不同的场景和需求。
解决活锁问题-限制重试次数
Java使用限制重试次数解决活锁问题的思想是在重试机制中设置一个最大重试次数,如果超过这个次数仍然失败,就抛出异常或者执行其他逻辑。这样可以避免无限循环地重试,造成资源的浪费和性能的下降。例如,如果有一个接口调用失败了,可以设置最多重试5次,如果还失败了,就记录日志并返回错误信息。 (demo 同上类似)
//定义一个接口调用方法
public class Service {
private Random random = new Random();
public void call() throws Exception {
//模拟一定概率的失败
if (random.nextInt(10) < 8) {
throw new Exception("调用失败");
} else {
System.out.println("调用成功");
}
}
}
//定义一个重试机制类
public class Retryer {
private int maxAttempts; //最大重试次数
public Retryer(int maxAttempts) {
this.maxAttempts = maxAttempts;
}
//执行一个任务,并在失败时进行重试
public void execute(Runnable task) throws Exception {
int attempts = 0; //当前尝试次数
while (true) {
try {
task.run(); //执行任务
break; //成功则退出循环
} catch (Exception e) { //捕获异常
attempts++; //尝试次数加一
if (attempts >= maxAttempts) { //达到最大尝试次数则抛出异常
throw new Exception("超过最大重试次数", e);
} else { //否则打印日志并继续重试
System.out.println("第" + attempts + "次调用失败,继续重试");
}
}
}
}
}
//创建一个服务对象和一个重试器对象,并执行任务
public class Test {
public static void main(String[] args) throws Exception {
Service service = new Service();
Retryer retryer = new Retryer(5);
retryer.execute(() -> service.call());
}
}
方案缺点:
- 如果重试次数限制有问题,容易出现请求放大的问题,导致下游服务压力过大。
- 如果对重试次数不加限制,可能出现在一段时间内的重试都失败,造成不必要的负荷和资源浪费。
- 如果当前的消息一直重试,可能导致后面的消息堆积起来,无法及时消费。
- 如果重试的场景不合适,比如写操作或者参数校验不合法,可能导致数据不一致或者异常。
解决饥饿问题-使用公平的调度策略
使用公平的调度策略解决饥饿问题的思想是保证所有等待的线程都有机会获得锁,而不是让某些线程一直占用锁,导致其他线程得不到执行。这样可以提高系统的吞吐量和响应时间。例如,如果有多个线程竞争一个共享资源,可以使用ReentrantLock类创建一个公平锁,设置fair参数为true,表示按照请求锁的顺序来分配锁。
//定义一个共享资源类
public class Resource {
private int value; //资源值
public Resource(int value) {
this.value = value;
}
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
//定义一个任务类,实现Runnable接口
public class Task implements Runnable {
private Resource resource; //共享资源对象
private ReentrantLock lock; //公平锁对象
public Task(Resource resource, ReentrantLock lock) {
this.resource = resource;
this.lock = lock;
}
@Override
public void run() {
try {
lock.lock(); //请求锁
System.out.println(Thread.currentThread().getName() + "获取了锁");
//模拟对资源的操作
int value = resource.getValue();
Thread.sleep(1000); //模拟耗时操作
resource.setValue(value + 1);
System.out.println(Thread.currentThread().getName() + "修改了资源值为" + resource.getValue());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); //释放锁
System.out.println(Thread.currentThread().getName() + "释放了锁");
}
}
}
//创建一个公平锁对象和一个共享资源对象,并启动多个线程执行任务
public class Test {
public static void main(String[] args) throws Exception {
ReentrantLock lock = new ReentrantLock(true); //创建一个公平锁对象,设置fair参数为true
Resource resource = new Resource(0); //创建一个共享资源对象,初始值为0
for (int i = 0; i < 5; i++) { //启动5个线程执行任务
Task task = new Task(resource, lock); //创建一个任务对象
Thread thread = new Thread(task, "Thread-" + i); //创建一个线程对象,并命名为Thread-i
thread.start(); //启动线程
}
}
}
方案缺点:
- 公平锁的缺点是按序唤醒线程的开销大,执行性能不高。因为队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。而非公平锁的优点是执行效率高,谁先获取到锁,锁就属于谁,不会“按资排辈”以及顺序唤醒。
解决饥饿问题-避免设置不同的线程优先级
JAVA中,可以通过setPriority(int newPriority)
方法来为每个线程设置不同的优先级。优先级的范围是1到10,其中1是最低优先级,10是最高优先级。线程的默认优先级和创建它的执行线程的优先级相同。
Atomic 类
Java中的Atomic类是用来实现原子性操作的类,它们利用**CAS(比较并交换)**机制来保证操作的原子性,而不需要使用锁。常用的Atomic类有以下几种:
- AtomicInteger:用来对整型变量进行原子性操作,提供了get、set、increment、decrement等方法。
- AtomicLong:用来对长整型变量进行原子性操作,提供了和AtomicInteger相同的方法。
- AtomicBoolean:用来对布尔型变量进行原子性操作,提供了get、set、compareAndSet等方法。
- AtomicReference:用来对引用类型变量进行原子性操作,提供了get、set、compareAndSet等方法。注意,这个类是更新引用,而不是更新对象本身。
- AtomicIntegerArray,AtomicLongArray和AtomicReferenceArray,它们分别对应int数组、long数组和引用类型数组的原子更新。
- AtomicIntegerFieldUpdater,AtomicLongFieldUpdater和AtomicReferenceFieldUpdater,它们分别对应int字段、long字段和引用类型字段的原子更新。
- AtomicMarkableReference和AtomicStampedReference,它们分别对应带有标记或版本戳的引用类型的原子更新。
使用Atomic类的示例代码:
- 使用AtomicInteger类来实现一个线程安全的计数器:java
class AtomicIntegerTest { private AtomicInteger count = new AtomicInteger(); //使用AtomicInteger之后,不需要对该方法加锁,也可以实现线程安全。 public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }
- 使用AtomicReference类来更新一个引用类型变量:java
class AtomicReferenceTest { private AtomicReference<User> userRef = new AtomicReference<>(); public void updateUser(User oldUser, User newUser) { userRef.compareAndSet(oldUser, newUser); } }
AtomicMarkableReference And AtomicStampedReference
带有标记或版本戳的引用类型的原子操作是一种解决ABA问题的方法。ABA问题是指,在多线程环境中,一个变量的值被修改了两次,但是最终还是恢复了原来的值,这样就可能导致其他线程误认为该变量没有被修改过。例如,假设有一个共享的引用类型变量A,其初始值为a1,线程T1想要将其更新为a2,但是在更新之前,线程T2将其修改为a3,并且又修改回a1,这样T1就会认为A没有被改变过,从而执行CAS操作成功。
为了避免这种情况,可以给引用类型添加一个标记或者版本戳,表示该引用类型被修改过多少次。Java提供了两个类来实现这种功能:
- AtomicMarkableReference:它可以给引用类型添加一个布尔型的标记(true或false),表示该引用是否被修改过。
- AtomicStampedReference:它可以给引用类型添加一个整型的版本戳(stamp),表示该引用被修改过多少次。
这两个类都提供了相应的get、set、compareAndSet等方法来进行原子操作。
AtomicMarkableReference
- 创建一个AtomicMarkableReference对象,传入初始的引用值和标记值。例如:
AtomicMarkableReference<String> amr = new AtomicMarkableReference<>("abc", false);
- 使用get方法获取当前的引用值和标记值。例如:
String s = amr.getReference(); boolean b = amr.isMarked();
- 使用compareAndSet方法比较并更新引用值和标记值。该方法接受四个参数,分别是期望的引用值、更新后的引用值、期望的标记值、更新后的标记值。如果当前的引用值和标记值与期望的一致,就更新为新的值,并返回true;否则不更新,并返回false。例如:
boolean result = amr.compareAndSet("abc", "def", false, true);
- 使用set方法直接设置引用值和标记值。该方法接受两个参数,分别是新的引用值和新的标记值。例如:
amr.set("xyz", false);
示例:
import java.util.concurrent.atomic.AtomicMarkableReference;
public class AtomicMarkableReferenceDemo {
// 创建一个AtomicMarkableReference对象,初始引用值为"abc",初始标记值为false
private static AtomicMarkableReference<String> amr = new AtomicMarkableReference<>("abc", false);
public static void main(String[] args) {
// 创建两个线程,分别尝试修改引用值和标记值
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
// 获取当前的引用值和标记值
String s = amr.getReference();
boolean b = amr.isMarked();
System.out.println("线程1获取的引用值:" + s + ",标记值:" + b);
// 模拟耗时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 尝试将引用值从"abc"改为"def",标记值从false改为true
boolean result = amr.compareAndSet(s, "def", b, true);
System.out.println("线程1修改结果:" + result);
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
// 获取当前的引用值和标记值
String s = amr.getReference();
boolean b = amr.isMarked();
System.out.println("线程2获取的引用值:" + s + ",标记值:" + b);
// 尝试将引用值从"abc"改为"ghi",标记值从false改为true
boolean result = amr.compareAndSet(s, "ghi", b, true);
System.out.println("线程2修改结果:" + result);
}
});
// 启动两个线程
t1.start();
t2.start();
// 等待两个线程结束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 输出最终的引用值和标记值
System.out.println("最终的引用值:" + amr.getReference() + ",标记值:" + amr.isMarked());
}
}
输出:
线程1获取的引用值:abc,标记值:false
线程2获取的引用值:abc,标记值:false
线程2修改结果:true
线程1修改结果:false
最终的引用值:ghi,标记值:true
AtomicStampedReference
//创建一个初始引用为null,初始戳记为0的AtomicStampedReference
String initialRef = null;
int initialStamp = 0;
AtomicStampedReference<String> atomicStampedReference = new AtomicStampedReference<String>(initialRef, initialStamp);
//获取引用和戳记
String reference = atomicStampedReference.getReference();
int stamp = atomicStampedReference.getStamp();
//设置新的引用和戳记
String newRef = "some value";
int newStamp = stamp + 1;
atomicStampedReference.set(newRef, newStamp);
//比较并设置新的引用和戳记,如果当前引用和戳记与期望值相同
String expectedRef = "some value";
int expectedStamp = 1;
String updatedRef = "new value";
int updatedStamp = expectedStamp + 1;
boolean wasUpdated = atomicStampedReference.compareAndSet(expectedRef, updatedRef, expectedStamp, updatedStamp);
CAS
比较并交换(Compare And Swap),一条CPU并发原语,用于判断内存中某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。它的基本思想是,给定一个内存位置(变量),一个期望值和一个新值,只有当内存位置的值与期望值相等时,才将内存位置的值更新为新值,并返回成功,否则返回失败。CAS操作可以通过CPU指令或者Java并发包中的原子类来实现。CAS操作可以避免使用锁来实现多线程之间的变量同步,提高了并发性能。