阅读 207

一篇文章看懂java并发编程

Hi,朋友们,大家好久不见。这两个月来发生了很多的事情,疫情爆发,不知道有多少的家庭深受其害,濒临破碎,也不知道有多少中小企业面临着复工难,无力发放工资的困局。在此国难之际,我们更应该信任我们的国家,积极配合工作,祈祷疫情早日结束,人民生活早日回归正轨。武汉加油,中国加油!

不过我们的学习还是要继续。在家不能外出的日子,看些文章深化一下知识也是个不错的选择呢。今天我想分享的主题是java并发编程。之前对并发编程有一些了解,但是感觉就没有形成体系。所以这段时间我又重新学习了一下并发编程,希望能把自己所学东西自然的形成体系并分享出来。本文较长,建议电脑上观看。

下面是java并发编程系列的大纲图: 



一.并发问题的源头

Java并发编程一直算是比较进阶的知识,相信很多的java程序员也曾经学习过一些零碎的知识,也都知道诸如volatile是轻量版的锁,会用synchronize来保证代码的同步。但是可能也有一些朋友并不知道该在什么场景下去使用对应的工具包,也就是不知道每种工具到底是解决了什么问题。我们先来聊聊为什么并发程序可能会导致各种诡异的bug。

随着技术的发展,CPU,内存和IO设备都有了巨大的进步。CPU核数从单核变化到多核,同一时刻CPU可能执行着很多个任务,或者一个任务也可能同时被多个CPU在运行着。内存和IO设备的速度和容量也日新月异。不过再怎么变化,有一个核心矛盾即三者的速度差异都一直存在着,并且差距之大如同天上一日,人间十年。我们的程序大部分都需要访问内存,有些还要访问IO,这样的话,整体性能都会被访问内存和IO的速度给限制着,CPU可能大部分时候都在苦巴巴的空等。

为了平衡三者的速度差异,提高CPU的利用率,计算机结构,操作系统,编译器都做出了一些优化,主要是:

  1. CPU增加缓存,把最近用到的数据放入缓存里,要比直接读取内存数据要快很多。
  2. 操作系统增加进程和线程的概念,以便分时复用CPU,均衡CPU与IO设备的速度差异
  3. 编译程序优化指令执行次序,使得上面提到的缓存中的数据可以更合理的利用

不过正是因为这些优化项,我们在写并发程序的时候才容易出那么多诡异的bug。

可见性问题

上面我们说到,CPU会增加缓存,来均衡CPU和内存的速度差异。在单核时候,所有线程都是在一个CPU上面运行,这个优化并不会带来问题。因为只有一个缓存,线程A在缓存中的写,等到CPU运行线程B的时候,线程B一定能看到这个写之后的结果。

一个线程对共享变量的修改,另一个线程能立刻看到,我们就称为可见性

但是到了多核时代,多个CPU,多份缓存,线程A在CPU A的缓存中的写操作,线程B在CPU B中却不一定能看到,因为他们是操作的不同的缓存,这个时候线程A的写操作对B而言就不具有可见性了。下面用个例子展示一下。

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行add()操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count;
  }
}
复制代码

我们用了两个线程在同一个线程里面分别把count加一了10000次,要是在单核CPU上面,结果就应该是20000,不过在多核CPU上面,结果可能就是个随机数,为什么呢?假设线程A和线程B同时开始运行,那么开始都是0,然后都增加1以后都同时写入内存,这时候内存中就是1而不是我们期望的2了。因为这样读取和写入内存都不确定,最终的结果可能就是个随机数。

这就是多个缓存带来的可见性问题,一个线程都数据的修改不能及时被别的线程看到。

原子性问题

为了提高CPU的使用效率,操作系统发明了多进程和线程。操作系统允许某个线程执行一个时间片的时间,然后就会重新选择一个线程来执行。这样在等待IO的时候,操作系统就可以把时间片让给别的线程,让别的线程去执行,提高CPU利用率。

这个设计看起来非常的自然,但是因为任务切换的时机大多数是在时间片结束的时候,但是现代的高级语言一条语句往往需要多条CPU指令,比如我们熟知的 count += 1 这个操作,就需要三条指令:

  • 把变量count从内存加载到CPU的寄存器
  • 在寄存器中执行 +1 操作
  • 把结果写回内存(缓存)。

时间片的切换,可能发生在任何一个CPU指令执行完的时候,这样两个线程切换的时候可能会导致一些奇怪的问题。这样就比较坑啦。比如这张图显示的,两个线程都执行了+1的操作,最终得到的结果不是我们期望的2,而是1.


我们把一个或多个操作在CPU执行的过程中不被中断的特性称为原子性。CPU只能保证指令级别的原子操作,而不是高级语言的操作符。所以我们需要通过别的方法保证操作的原子性。

有序性问题

编译器为了优化性能,有时候会改变程序中语句的执行顺序。例如程序中:“a=6;b=7;”,编译器优化后可能变成“b=7;a=6;”。在这个例子中,编译器的调整并没有影响程序的最终成果。但是有些时候也会产生意想不到的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;
  }
}
复制代码

上面的代码乍看之下好像没啥问题,可能有些同学看了上面原子性问题的部分会有所想法,会不会是这里Singleton的初始化语句的问题呢?没错,问题确实出在这里。因为new操作并不是个原子性操作。它实际上包含了三步:

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

操作系统可能会优化为1-->3-->2的操作顺序。这样的话,假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。


解决办法是啥呢?加volatile修饰变量,或者改为静态内部类单例

小结

并发程序通常会出现各种诡异问题,可能乍看之下非常的无厘头,不知从何查起,但是只要我们深刻的理解了可见性,原子性,有序性,我们就能知道在某些场景下可能会出现什么问题,并且知道java提供的并发工具各自都是在解决什么问题。后面的并发理论基础,我们会介绍Java是如何解决这些问题的。

二.并发理论基础

上面我们讲到了并发程序常见的可见性,原子性和有序性问题,接下来我们就介绍java为了解决这些问题都做了什么努力。我将其分为java内存模型(JMM, Java Memory Model)和并发编程基础(主要是线程相关的并发知识)。这两项都是相当重要的并发编程背景知识,可以帮助我们更好的理解和编码。

Java内存模型(JMM)

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步。这里的同步指的是程序中用于控制不同线程间操作发生相对顺序的机制。常见的线程的通信机制有两种,共享内存消息传递。这两种机制在两个关键问题的处理上有一些区别:

1.共享内存:在共享内存的并发模型中,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。之所以是说隐式通信,是因为两个线程没有直接联系,但是通过共享内存又拿到了对方的相关结果。但是同步是显式的,程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。比如下图


2.消息传递:在消息传递的并发模型中,线程之间没有公共状态,线程之间需要互相发送消息,所以通信是显式的。同时由于发送消息和接收消息有先后顺序,所以两个线程之间相对顺序就已经隐式的指定了。比如下图


Java使用的是共享内存模型。程序员需要了解隐式通信的工作机制,否则就可能遇到各种奇怪的内存可见性问题。

JMM基础

1.抽象结构
java线程之间的通信由JMM控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。同时它定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,存储着该线程已读/写共享变量的副本。当然这里的本地内存是抽象概念,包括了上面讲到的缓存,寄存器等等。代表着线程存储本数据的地方。

注意:这里提到的共享变量指的是实例域,静态域和数组元素之类的存储于堆内存中的变量。只有堆内存才能被线程之间共享。不太清楚的可以参考JVM的堆和栈

Java内存模型的抽象示意如图所示。正如我们前面所讲,主存中存储着共享数据,每个线程中的私有内存存储着共享变量的副本。


2.重排序
第一节中,我们也讲到了为了提高性能,编译器和处理器经常会对指令做重排序。重排序分为三种:

  • 编译器优化重排序。即编译器在不改变as-if-serial语义的前提下,可以重新安排语句的执行顺序。可能大家不太as-if-serial语义是啥意思,后面会再讲到。
  • 指令级并行重排序。如果不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序
  • 内存系统重排序。由于处理器使用缓存和读/写缓冲区,这使得读写操作看上去可能是在乱序执行。

上面三种,第一种属于编译器重排序,2和3属于处理器重排序。为了解决重排序带来的可见性和有序性问题,JMM会禁止特定类型的编译器重排序,并且通过插入内存屏障的方式来精致特定类型的处理器重排序。内存屏障我们会在后面讲到。

这里朋友们可能就有疑问了,编译器和处理器会在什么情况下禁止重排序呢,有没有什么判断依据?这里主要的依据是两个操作间有没有数据依赖性

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,那么这两个操作间就存在数据依赖性。这么一看是不是很好判别,只要有写操作就可以认为是有数据依赖性了。比如写后读(a=1, b=a),写后写(a=1, a=2),读后写(a=b, b=1)。这三种类型只要有了重排序,执行结果就会被改变,编译器和处理器会禁止有数据依赖性的两个操作的执行顺序。

介绍到这里,我们就可以引出as-if-serial语义了。as-if-serial语义指的是不管编译器和处理器怎么重排序,单线程程序的执行结果不能被改变。编译器和处理器都必须遵守as-if-serial语义。这是我们的程序能稳定且符合预期运行的保障。举个栗子,假设我们有个程序

a = 1
b = 2
c = a * b
复制代码

那么a和b可以重排序,但是a和c,b和c就不能重排序,因为他们有数据依赖性

3.总线工作机制
上面我们讲到了,缓存会导致可见性的问题是因为缓存刷入内存不及时,那么问题来了,如果很及时,两个线程同时写入缓存,然后两个缓存同时写入内存,这样内存中的数据会冲突吗?答案是不会,这与总线工作机制密切相关。

在计算机中,数据通过总线在处理器和内存之间传递,每次处理器和内存之间的数据传递都是通过一系列步骤完成的,称之为总线事务。总线事务包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存。并且,总线会同步试图并发使用总线的事务。也就是说同一时刻只有一个处理器在执行总线事务。其余的处理器需要等待前一个处理器完成事务后才能执行操作。


4.总结
本小节我们讲到了JMM的抽象结构,让大家看到了缓存是怎么样影响到数据同步的,同时也介绍了重排序的类型和判别重排序的依据-数据依赖性,最后介绍了总线的工作机制,旨在于让大家形成对JMM工作原理的初步认识,能认识到这些机制是保障程序稳定运行的前提。

Happens-before规则

Happens-before是JMM最核心的概念,作为Java程序员,理解了Happens-before规则,就理解了JMM的关键,相当于打通了任督二脉。对于这一块,大家务必提起干劲。

1.设计思路
JMM设计之初,设计者们需要考虑到两方的重要需求:

  • 程序员对内存模型的使用:希望易于理解,易于编程,不需要了解到方方面面,但是又要稳定可靠
  • 编译器和处理器对内存模型的实现:希望约束尽可能少,这样它们可以做尽量多的优化

设计者们为了平衡两者的需求,想了个好办法,对上层的程序员,提供一套happens-before规则,程序员基于这套规则提供的内存可见性保障来编程。而对下层的编译器等,要禁止掉会改变程序执行结果的重排序。除此之外,编译器等想怎么优化都行,比如把没用的加锁和volatile变量处理给去掉等。


2.定义
happens-before概念用于指定两个操作之间的执行顺序,这两个操作可以是一个线程的,也可以是不同线程之中。它的定义是:如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见。比如操作A读写了a和b变量,同时操作A是happens before操作B的,那么在操作B执行的时候,它是能看到操作A完成以后的a和b变量的结果的。

下面我们还是用之前的例子。

a = 1        // 操作1
b = 2        // 操作2
c = a * b    // 操作3
复制代码

对于这个例子, 操作1 happens-before 操作2 操作1 happens-before 操作3 操作2 happens-before 操作3

但是要注意的是,happens-before规则不一定对应程序执行顺序,这也是设计者们对于编译器和处理器的“放水”。就数据依赖性而言,3个happens-before关系中,2和3是必须的,但是1不是必要的。所以操作1和操作2的执行顺序是可以颠倒的。编译器和处理器在这种情况下就会尽可能的进行优化。

3.happens-before规则
java中一共定义了六条happens-before规则:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
  3. volatile变量规则:对一个volatile变量的写,happens-before于任意后续对这个volatile变量的读
  4. 传递性:如果A happens-before B,B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start(), 那么A线程的ThreadB.start操作happens-before于线程B中的任意操作
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

由于编译器和处理器都必须满足as-if-serial语义,as-if-serial语义进一步保证了程序顺序规则。因此程序顺序规则相当于我们前面提到的as-if-serial语义的封装。 这里的start和join规则主要是提供了线程切换时候的可见性保证,而前面四条规则提供了我们日常使用到的各种工具的可见性保证。

happens-before非常重要,对于后面我们理解锁和工具集的实现原理十分关键。大家先记着,等后面会经常性的用到。

volatile内存语义

大多数java学习者在一开始学习volatile的时候,都是记得:volatile可以看做是轻量级的锁,其修饰的变量的变更能够保证多线程的可见性。我刚学volatile的时候,以为volatile只是java提供的一个小小的工具,但是看到java的happens-before规则中专门有volatile的一项,就感觉真的不是那么简单。

我们可以把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。所以volatile变量具有下列特性:

  • 可见性。对volatile变量的读,总是能看到任何线程对这个变量最后的写入。
  • 原子性。对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

介绍完了volatile变量的特性,我们结合上面提到的JMM内存抽象结构介绍下JMM对于volatile变量读写的操作。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中,读的时候,volatile变量强制从主内存中读取。这样就相当于禁用了CPU缓存。这里需要注意到的是JMM不止会把volatile变量刷新到主内存,而是把本地内存中的所有共享变量。这个特性被用到了java concurrent包的各种工具类中。下面用个小栗子来说明。

public class VolatileExample {
    int x = 0;
    volatile boolean v = false;
    public void writer() {
        x = 42;
        v = true;
    }
    public void reader() {
        if (v == true) {
            System.out.println("x = " + x);
        }
    }
}
复制代码

比如这个类,一个线程执行writer,然后另一个线程再执行reader,就会输出x=42,这就是因为共享变量在volatile变量写的时候都被刷入进去主内存了。注意,volatile变量一定要最后写,最先读。

接下来可以总结volatile写和volatile读的内存语义:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出来(其对共享变量所做修改的)消息
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

看到这里,大家对JMM的共享内存模型隐式通信的特点是不是认识更深刻些了?

内存屏障

上面介绍完了volatile的内存语义,接下来看看JMM如何实现volatile的内存语义。之前我们提到过重排序分为编译器重排序和处理器重排序,为了实现volatile内存语义,JMM会限制这两种重排序类型。


可以看出来

  • 第二个操作是volatile写时候,不管第一个操作是啥,都不能重排序
  • 第一个操作是volatile读时,不管第二个操作是啥,都不能重排序
  • 第一个操作是volatile写,第二个是volatile读时,不能重排序

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止处理器重排序。内存屏障的作用有两个:1是阻止屏障两侧的的指令重排,2是强制把高速缓存中的数据更新或者写入到主存中。Load Barrier负责更新高速缓存, Store Barrier负责将高速缓冲区的内容写回主存

JMM会采取保守策略,保证任何情况下都能得到正确的volatile语义。

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。这一步主要目的是防止volatile写和可能有的volatile读发生重排序
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

这里以volatile写为例,讲解下屏障的意义。如下图所示,volatile写的前后分别被插入了StoreStore和StoreLoad屏障,插入之后,处理器就不能对指定类型进行重排序了。你可以把内存屏障看做一个栏杆,代码想越也越不了,只能按照固定顺序执行。


可能看到这里你就会有疑问了,上面我们讲到第二个操作是volatile写时候,不管第一个操作是啥,都不能重排序,但是这里只给volatile写前面加了个StoreStore,为啥没有LoadStore呢?其实这里我也没有想明白,欢迎理解了的同学指点一下。

锁内存语义

锁是java中最重要的同步机制。大家可能从一开始学java就会把synchronized用上。接下来我们就介绍下锁的内存语义。

当线程释放锁的时候,JMM会把线程对应的本地内存中的共享变量刷新到主内存上。当另一个线程获取锁的时候,JMM会把该线程对应的本地内存置为无效,从而被监视器保护的临界区代码必须从主内存中读取共享变量。这点我们也可以从happens-before规则中推导出来。


按照程序顺序规则,1 happens-before 2 happens-before 3, 4 happens-before 5 happens-before 6。 按照锁规则,3 happens-before 4。 所以按照传递规则,2 happens-before 5。也就是说,前一个线程在临界区进行的修改,都会后续获得锁的线程可见。

看到这里,大家是不是觉得加锁和释放锁,和上面的volatile读和写操作是分别对应的,具有一样的内存语义。总结如下:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出来(其对共享变量所做修改的)消息
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

锁语义的实现

下面我们借助于ReentrantLock来分析锁内存语义的具体实现机制。ReentrantLock依赖于AbstractQueuedSynchronizer(后面简称AQS)。AQS使用整型的volatile变量state来维护同步状态。ReentrantLock调用方式分为公平锁和非公平锁:

  • 公平锁:在锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。根据上面我们讲到的volatile的happens-before规则,释放锁的线程在写volatile变量之前对共享变量进行的修改,在别的线程读取同一个volatile变量后将立即可见。
  • 非公平锁:非公平锁的释放和公平锁完全一样,而获取则采用的是compareAndSet方法。

这里我们就引入了compareAndSet方法,这个方法也常被缩写为CAS。它的作用是,如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。可以看到CAS操作满足了原子性和可见性。处理器在处理CAS方法时候,会给交换指令加上lock前缀。而lock前缀的特征如下:

  • 确保对内存的读-改-写操作原子执行
  • 禁止该指令,与之前和之后的读和写
  • 把写缓冲区中的所有数据刷新到内存中

后面两点就使得CAS完全具有了volatile读和写的内存语义。

因此,锁的内存语义的实现实际上可以有两种方式:

  • 利用volatile变量的写-读具有的内存语义
  • 利用CAS附带的volatile写和读的内存语义。

当然还可以利用volatile和CAS的自由组合。我们在分析concurrent包的源代码实现时候,就会发现通用化的实现模式:

  1. 声明共享变量为volatile
  2. 使用CAS的原子条件更新来实现线程之间的同步
  3. 配合volatile的读/写和CAS来实现线程之间的通信。

可以说,volatile和CAS就是java并发编程的基石


线程与并发

上面讲完了内存模型,下面我们来讲讲偏java实现的概念,即并发编程中的线程是如何工作的。这里我主要是讲到线程的监视器等待/通知机制

监视器

任何一个对象都有自己的监视器。大家可以看看Object的几个方法,wait(), notify(), notifyAll(), 这是都是跟监视器相关的。当这个对象由同步块或者同步方法调用时,执行方法的线程必须先获取到监视器才能进入同步区域。我们可以把监视器看做房间的大门。只有拿到门锁才能进入房间。而没有取到监视器的线程则会阻塞在入口处,线程会进入BLOCK状态。我们就会说线程被阻塞了。


等待/通知机制

大家都知道,我们常使用的synchronized就是一个单线程监视器锁,只有一个线程能获得监视器并进入临界区执行代码,而其他的线程则会进入到同步队列中,等待线程释放了监视器以后,收到Monitor.Exit通知才能继续去尝试获得监视器。

那么问题来了,这里不是只要等着线程A释放监视器,线程B就能去获取锁了吗,那么Object的wait和notify操作是拿来干嘛的呢?

其实现实状态并不会像想的那么理想,线程B获取到了锁,但是这时候可能条件并不满足,线程B并不能往下执行。比如线程B只能在flag=true的情况下执行,但是当它获取到监视器的时候,flag=false,那么如果线程B实在是想执行后面的操作的话,就有两种办法:

  • 占住监视器,循环等待,每隔一段时间判断flag是否为true
  • 释放监视器,等flag为true的时候,由别的对象通知它,它再去获取监视器执行。

可想而知,第二种效率更高,更及时。这也是我们说到的等待/通知机制

这里我们看到,当条件不满足的时候,可以通过条件变量的wait()方法将当前正在执行的线程放入条件变量的等待队列,当条件满足的时候,调用条件变量的notify或者notifyAll方法将线程从等待队列中移出。那么有些同学就会犯迷糊了。这里的等待队列和上面说到的因为没获取到监视器的线程的阻塞队列有区别吗?

当然有区别!上面的阻塞队列是没获取到监视器,这里的等待队列是获取到监视器,但是继续运行的条件没有满足,因此自己陷入等待状态的队列。两者是不同的概念。以追妹子为例,我们可以理解为阻塞队列是排队进妹子家的大门,进了大门才能和妹子聊天,但是聊完之后就要进入到备胎池等着妹子选择佳婿了。等妹子选好以后接到妹子电话,就可以重新排队登门和妹子谈恋爱了。


使用wait(),notify()和notifyAll()有些需要注意的细节:

  • 使用这三个方法需要先对调用对象加锁
  • 调用wait方法后,线程状态由Running变为Waiting,并将当前线程放置到对象的等待队列
  • notify或notifyAll方法调用后,等待线程不会从wait返回,而是要重新获取到锁以后才能返回。
  • notify将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll是将所有线程全部移到同步队列。

三.总结

本文讲到了java内存模型和线程的同步机制。java内存模型的重点是happens-before规则,volatile和锁。只要这些东西能了解,concurrent包里的实现范式我们就能看懂了。了解了线程的等待/通知模型,我们就能对锁的使用更加的了然于心。

本文就到这里啦。后续可能会学习下java的并发工具的设计和实现,如果有值得分享的东西,会另外再写文章分享出来,敬请期待。

参考

《java并发编程的艺术》
《java并发编程实战》王宝令

我是Android笨鸟之旅,笨鸟也要有向上飞的心,我在这里陪你一起慢慢变强。期待你的关注



关注下面的标签,发现更多相似文章
评论