Java并发知识汇总

1.并发编程领域的三个核心问题:分工、同步、互斥

分工

在并发领域里,分工直接决定了并发程序的性能。

Java的SDK里如Executor、Fork/Join、Future本质上都是分工方法;又如并发编程里的设计模式,生产者-消费者、Thread-Per-Message、Worker Thread 模式都是指导你如何进行分工的。

同步

在并发模型中,同步主要指的是线程间的协作,一个线程执行完一个任务后,如何通知后续执行的线程;

如用 Future 可以发起一个异步调用,当主线程通过 get() 方法取结果时,主线程就会等待,当异步执行的结果返回时,get() 方法就自动返回了。

工作中遇到的协作问题:当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。

管程:管程是解决并发问题的一种通用模型,线程协作技术底层就是通过管程解决的,管程还能解决互斥问题,管程是解决并发问题的万能钥匙。

互斥

指的是在同一时刻,只允许一个线程访问共享变量;

分工、同步强调的是性能方面的问题,而互斥是解决线程安全问题的核心方案。

实现互斥的核心技术就是锁,Java 语言里 synchronized、SDK 里的各种 Lock 都能解决互斥问题。但是这同样也带来了性能问题,Java SDK 里提供的 ReadWriteLock、StampedLock 就可以优化读多写少场景下锁的性能。还可以使用无锁的数据结构,例如 Java SDK 里提供的原子类都是基于无锁技术实现的。

除此之外,还有一些其他的方案,原理是不共享变量或者变量只允许读。这方面,Java 提供了 Thread Local 和 final 关键字,还有一种 Copy-on-write 的模式。当然除了性能之外,还要注意死锁问题。


2.原子性、有序性与可见性问题

可见性

一个线程对共享变量的修改,另外一个线程能够立即看到,即为可见性。

CPU缓存导致的可见性问题:在多核CPU时代,每颗CPU都有自己的缓存,当多个线程在不同的CPU上执行,操作的是不同的CPU缓存,影响了可见性,导致出现数据不一致问题。

原子性

我们把一个或多个操作在CPU执行中不被中断的特性称为原子性。

多线程切换:线程只有被分配到时间片才会执行,时间片执行完则会分配时间片给另一个线程执行任务。

多线程切换给Java并发程序带来的原子性问题:高级语言编程是由多条CPU指令来完成的,如count+=1,需要3条指令来实现:

  1. 把变量count=0从内存读到CPU寄存器中;
  2. 在寄存器中执行+1操作;
  3. 将结果写入到内存中(缓存机制导致可能写入到CPU缓存中)。

而如果是两个线程同时执行count+=1,最终得到的结果可能是1而不是2,因为第一个线程在执行指令2前时间片用完,切换到另外一个线程执行,而这时候他们CPU寄存器中count的值都是0,导致最后写入内存的结果都是1。

CPU能保证的原子级别操作时指令级的,而不能保证高级语言的操作符原子性,所以很多时候我们都要在高级语言层面保证操作原子性。

有序性

有序性指的时程序按照我们代码的先后顺序来执行。

编译器优化带来的有序性问题:编译器为了优化性能,有时候会改变程序语句的执行顺序,这有时也会导致出现BUG。比如java领域一个经典的创建单例对象的双重检测机制:

public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

这里首先对instance判空,然后锁住Singleton,如果这时候线程切换了,另外一个线程也进来了,等待获取锁。但是还有一次判空,如果其中一个线程初始化完成,另外一个线程就直接返回了。

看起来是很完美,但是就是因为编译器优化,在new Singleton()这一部就会可能出现问题,new的时候需要执行这三部操作:

  1. 分配一块内存M;
  2. 在内存M上初始化Singleton对象;
  3. 将地址赋值给instance变量。

而编译器优化之后的顺序就会编程1、3、2,则是先把地址赋值给instance变量,此时如果发送线程切换,另外一个线程就会判断instance!=null,然后直接返回未初始化完成的instance对象,就可能会出现空指针异常。


3.Java内存模型

volatile关键字最原始的意义就是为了禁用CPU缓存,告诉编译器必须从内存读取或者写入。

在jdk1.5版本后对volatile语义进行了增强,加入了Happens-Before规则。

Happens-Before 规则

指的是前面一个操作的结果对后续操作是可见的。

程序的顺序性规则:按照程序中的顺序执行,前面的操作Happens-Before于后续的任意操作,比如下面的代码:

class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里 x 会是多少呢?
}
}
}

这里x=42的操作按照Happens-Before规则,对于后续v=true是可见的;

而根据volatile 变量规则,对volatile变量进行写操作,对于后续的读操作是可见的;并且它们具有传递性,如上面代码的例子,因为x=42对于变量v是可见的,而后续另外一个线程在读v的时候,x=42对于它也是可见的,读取到x的值将是42。而1.5版本之前可能会因为CPU缓存导致读取到x的值是0。

管程中锁的规则:管程是一种通用的同步原语,在Java中指的就是synchronized,synchronized是Java对管程的实现。管程中的锁在synchronized里是隐式实现的,在进入同步块之前,会进行加锁,同步块执行完之后自动释放锁。而管程中锁的规则就是在synchronized代码块中对变量的修改是可见的,即一个线程进入此代码块时能够看到另一个线程对变量的修改结果。

synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
this.x = 12;
}
} // 此处自动解锁

如线程A获取锁进入此代码块,修改x的值为12,然后释放锁;此时线程B获取到锁,获取到x的值是12,这就是管程中锁的规则。

线程start()规则:指主线程A启动子线程B后,子线程B能够看到线程A在启动B之前对共享变量的所有操作。

线程join()规则:指主线程A等待子线程B执行完成后(调用B的join()方法),主线程A能够看到子线程对共享变量的所有操作。

java1.5版本之后也对final进行了重排约束。


4.互斥锁

解决原子性问题的方案就是阻止CPU中断,而我们只需要保证同一时刻只有一个线程执行,保证对共享变量的修改是互斥的,保证中间状态对外不可见,就能保证原子性了。

Java中的锁synchronized

隐式规则:

  1. 当修饰静态方法的时候,锁定的是当前类的 Class 对象;
  2. 当修饰非静态方法的时候,锁定的是当前实例对象 this。

锁性能优化:用不同的锁对受保护资源进行精细化管理,能够提升性能,即细化锁粒度。


5.死锁

形成死锁的四个必要条件:

  1. 互斥,共享资源 X 和 Y 只能被一个线程占用;
  2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

只需要破坏其中一个条件就可以避免死锁,其中互斥这个条件不能被破坏,因为我们加锁就是为了互斥。

  1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

在破坏“占用且等待”这个条件的时候,我们判断如果不满足所有的条件就死循环一直等待获取锁,如果并发量太大的情况会很消耗CPU,所以可以用等待-通知机制来优化。

等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,再通知等待的线程重新获取互斥锁。

用synchronized实现等待-通知机制:当不满足条件时调用wait()方法释放互斥锁进入等待状态;当要求的条件满足时,调用notify()或notifyAll()方法通知条件曾经满足过(只保证在通知时间点条件时满足的,不保证有线程插队的现象),此时线程仍然需要重新获取锁;

注:wait()、notify()、notifyAll() 都是在synchronized{}内部使用的,都是根据当前锁住的对象来调用方法的。一般情况下尽量使用notifyAll(),这样才能通知到等待队列的所有线程。

class Allocator {
private List

posted @ 2021-11-14 16:53  Conwie  阅读(167)  评论(0)    收藏  举报