一文梳理java并发编程知识点

本文梳理了java并发编程中的一些知识点,包括线程状态。锁的分类、原理等,主要涉及线程并发知识,不涉及JDK21协程相关内容,第1-7小节是基础用法和简单原理,供侧重使用的读者查看,第8小节之后是深入分析原理,有兴许的读者可以阅读了解。

1. 线程状态

New: 尚未启动的线程的线程状态。

Runnable: 可运行线程的线程状态,等待CPU调度。

Blocked: 线程阻塞等待监视器锁定的线程状态, 处于synchronized同步代码块或方法中被阻塞。

Waiting: 等待线程的线程状态。下列不带超时的方式:

Object.wait、Thread.join、 LockSupport.park

Timed Waiting:具有指定等待时间的等待线程的线程状态。下 列带超时的方式:

Thread.sleep、0bject.wait、 Thread.join、 LockSupport.parkNanos、 LockSupport.parkUntil

Terminated: 终止线程的线程状态。线程正常完成执行或者出现异常。

请添加图片描述

NEW

​ 实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态, 线程还是没有开始执行.

RUNNABLE

  • 调用start(),进入可运行态

  • 当前线程sleep()结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入可运行状态

  • 当前线程时间片用完,调用当前线程的yield()方法,当前线程进入可运行状态

  • 锁池里的线程拿到对象锁后,进入可运行状态

  • 正在执行线程必属于此态

  • 它表示线程在JVM层面是执行的,但在操作系统层面不一定,它举例是CPU,毫无疑问CPU是一个操作系统资源,但这也就意味着在等操作系统其他资源的时候,比如 IO,CPU时间片 等,线程也会是这个状态

BLOCKED

  • 被挂起,线程因为某种原因放弃了cpu timeslice,暂时停止运行

  • 线程在阻塞等待monitor lock(监视器锁)

  • 一个线程在进入synchronized修饰的临界区的时候,或者在synchronized临界区中调用Object.wait然后被唤醒重新进入synchronized临界区都对应该态。

  • 结合上面RUNNABLE的分析,也就是I/O阻塞不会进入BLOCKED状态,只有synchronized会导致线程进入该状态

WAITING

  • 线程拥有对象锁后进入到相应的代码区后,调用相应的“锁对象”的wait()等操作,等待被唤醒,被唤醒后也需要再次获得锁,所以唤醒后进入blocked状态

TIMED_WAITING

  • 类似waiting,不需要特殊操作唤醒,等待时间到了自动唤醒,进入blocked状态

Terminated

  • Terminated:线程已终止,因为run()方法执行完毕。

2. 线程操作对应的线程状态及jstack

请添加图片描述

请添加图片描述

Jstack结果说明

一条典型的jstack线程栈格式如下:

"线程名" [daemon] prio= os_prio= tid= nid= 线程动作 [线程栈的起始地址]
    java.lang.Thread.State:线程状态 [(进入该状态的原因)]
     方法调用栈
     [-调用修饰]

    Locked ownable synchronizers:
        - <地址> (可持有同步器对象)

第一行说明线程相关信息,包括:

  1. 线程名。
  2. 是否守护线程,daemon标识,非守护线程没有。
  3. 线程优先级。
  4. 线程操作系统优先级。
  5. 线程id。
  6. 操作系统映射的线程id,十六进制字符串,使用这个id与实际操作系统线程id关联。
  7. 线程动作。
  8. 线程栈的起始地址。

线程动作

需要特别说明的是,线程动作,它提供的额外信息利于定位问题;线程动作包括:

  1. runnable: 线程可执行,对应的线程状态一般为RUNNABLE, 也有例外对应TIMED_WAITING。
  2. waiting on condition: 调用park阻塞、等待区等待、juc await等待。 condition 与 juc 中的Condition 不是一个,当是sync等待时,可能是monitor对象?
  3. waiting for monitor entry: 处于Monitor Entry Set,对应的线程状态一般是BLOCKED。
  4. in Object.wait(): 处于Monitor Wait Set,状态为WAITING或TIMED_WAITING。
  5. sleeping: 调用了sleep,休眠。

第二行是线程的状态,是java.lang.Thread.State中的一种;后面的括号里是进入该状态的原因(可选)。

方法调用栈紧随其后,重要的同步信息也会被输出。

调用修饰

调用修饰是线程方法调用过程中,重要的同步信息;调用修饰包括:

  1. locked <地址> (目标): 使用synchronized成功获得对象锁,即Monitor的拥有者。
  2. waiting to lock <地址> (目标): 使用synchronized获取对象锁失败,进入Entry Set等待。
  3. waiting on <地址> (目标): 使用synchronized获取对象锁成功后,必要条件不满足,调用object.wait()进入Wait Set等待。
  4. parking to wait for <地址> (目标): 调用park。

同步原语park比较特殊,不属于Monitor机制,他是锁机制的基础支持。由Unsafe类的native方法park实现。

最后一行是指定了 -l 选项才会输出的,额外的锁信息。表示当前线程获得的可持有同步器。由此可见Monitor机制(synchronized系列)的得天独厚,线程方法栈对Monitor机制的同步信息进行了详尽的说明。Monitor同步机制下,Locked ownable synchronizers为None。由于锁机制的Lock只是普通的java类,jvm无从得知其详尽的线程同步情况,因此使用锁机制实现的线程同步,出现问题时,不如monitor机制的同步实现,不利于辨识。

Jstack 样例

jstack 日志为线程当前执行状态,以及调用到线程当前方法的调用栈,还有获取锁的日志信息

1.sleep 是Thread.sleep ,不需要在sync代码块中,所以跟sync代码块绑定的object 不相关,不会释放sync代码锁

"a()   run" #13 prio=5 os_prio=0 tid=0x00000000596aa800 nid=0x1928 waiting on condition [0x000000005a6bf000] /**此处是等待条件**/
   java.lang.Thread.State: TIMED_WAITING (sleeping)  /** 此处有sleep 状态 **/
	at java.lang.Thread.sleep(Native Method)
	at com.lm.thread.test.ThreadWait.lambda$a$2(ThreadWait.java:67)
	- locked <0x00000000d625f470> (a java.lang.Class for com.lm.thread.test.ThreadWait)
	at com.lm.thread.test.ThreadWait$$Lambda$3/1364335809.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:745)

2.wait wait(long)

必须在同步块内执行,获取同步锁后,释放锁,并进入wait set,等待 notify 唤醒

/** 日志中能看到有locked  然后waiting on 同一个锁的情况,表示拿到了锁,又释放并进入了wait set**/
"wait" #12 prio=5 os_prio=0 tid=0x00000000596a1800 nid=0x4378 in Object.wait() [0x000000005a42f000]
   java.lang.Thread.State: TIMED_WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000000d625f470> (a java.lang.Class for com.lm.thread.test.ThreadWait)
	at com.lm.thread.test.ThreadWait.lambda$main$1(ThreadWait.java:35)
	- locked <0x00000000d625f470> (a java.lang.Class for com.lm.thread.test.ThreadWait)
	at com.lm.thread.test.ThreadWait$$Lambda$2/693632176.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:745)

3.notify notifyAll wait(long)

通知对象从wait set中释放,重新进行锁竞争,若竞争不到是boock状态呢

/** 此jstack 日志为先拿到ThreadWait锁进入同步代码块之后,调用ThreadWait.class.wait 到时后唤醒,又被阻塞**/
"wait" #12 prio=5 os_prio=0 tid=0x00000000596a1800 nid=0x4378 waiting for monitor entry [0x000000005a42f000]
   java.lang.Thread.State: BLOCKED (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x00000000d625f470> (a java.lang.Class for com.lm.thread.test.ThreadWait)
	at com.lm.thread.test.ThreadWait.lambda$main$1(ThreadWait.java:35)
	- locked <0x00000000d625f470> (a java.lang.Class for com.lm.thread.test.ThreadWait)
	at com.lm.thread.test.ThreadWait$$Lambda$2/693632176.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:745)

4.synchronized

"a()   run" #13 prio=5 os_prio=0 tid=0x0000000059849000 nid=0x389c waiting for monitor entry [0x000000005a78f000]
   java.lang.Thread.State: BLOCKED (on object monitor) 
	at com.lm.thread.test.ThreadWait.lambda$a$2(ThreadWait.java:63)
	- waiting to lock <0x00000000d6305d38> (a java.lang.Class for com.lm.thread.test.ThreadWait) /**阻塞在哪个锁**/
	at com.lm.thread.test.ThreadWait$$Lambda$3/1364335809.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:745) 

5.join()

等待目标线程执行完成后再继续执行

public class ThreadCode {
    public static void main(String[] args) throws InterruptedException{
        Thread t = new Thread(){
            public void run(){
                System.out.println("hello");
            }
        };
        System.out.println("start");
        t.start();
        t.join();
        System.out.println("end");
    }
    
main线程在启动t线程后可以通过t.join()等待t线程结束后再继续运行:

6.yield()

线程礼让,目标线程由运行状态转换成就绪状态,也就是让出执行权限,让其他线程得以优先执行,但其他线程能否优先执行是未知的.

Note: 让出执行时间并不是让出持有的对象锁

Note: yield()调用之后,其他线程并不一定会抢占到 执行时间

7.condition.await LockSupport.park整体上来看 Object 的 wait 和 notify/notifyAll 是与对象监视器 Synchronized 配合完成线程间的等待/通知机制,而Condition 的 await和 signal/signalAll 是与 Lock 配合完成等待通知机制。前者是java底层级别的,后者是语言级别的,两者用法基本类似,后者具有更高的可控制性和扩展性。

底层也是调用 LockSupport.park、LockSupport.unpark

signal/signalAll 唤醒顺序:先睡眠先唤醒

~ jstack 5589
"A-Name" #11 prio=5 os_prio=31 tid=0x00007fc143009800 nid=0xa803 waiting on condition [0x000070000c233000]
   java.lang.Thread.State: WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x000000076adf4d30> (a java.lang.String)
        at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
        at com.github.locksupport.LockSupportTest$1.run(LockSupportTest.java:18)
        at java.lang.Thread.run(Thread.java:745)

LockSupport.park 栈

public class Lock {
    public void park(){
        //传入blocker,当线程被阻塞时,jstack会打印输出具体阻塞对象的信息,方便错误排查
          LockSupport.park(this);
    }
    public static void main(String[] args) {
        new Lock().park();
    }
}

"Thread-0" #11 prio=5 os_prio=0 tid=0x000000005964d800 nid=0x3794 waiting on condition [0x0000000058e1f000]
   java.lang.Thread.State: WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
       //传入的blocker
	- parking to wait for  <0x00000000d604b0a0> (a JUC.Lock$MyThread)
	at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
	at JUC.Lock$MyThread.task(Lock.java:20)
	at JUC.Lock$MyThread.run(Lock.java:9)

3. 并发基础知识-java内存模型

并发编程模型的两个关键问题

  • 线程间如何通信?即:线程之间以何种机制来交换信息
  • 线程间如何同步?即:线程以何种机制来控制不同线程间操作发生的相对顺序

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型

这两种模型之间的区别如下表所示:

如何通信如何同步
消息传递并发模型线程之间没有公共状态,线程间的通信必须通过发消息来显式进行通信发消息自然同步,发消息总是在接收消息之前,因此同步是隐式的。
共享内存并发模型线程之间共享程序的公共状态,通过写-读内存中的公共状态隐式通信必须显式指定某段代码需要在线程间互斥执行,同步是显式的。

在Java中,使用的是共享内存并发模型

**Java内存模型的抽象结构 **

java运行时数据区

  • 所有线程共享数据区

    • 方法区
  • 线程私有数据区

    • 虚拟机栈
    • 本地方法栈
    • 程序计数器

    对于每一个线程来说,栈都是私有的,而堆是共有的。也就是说在栈中的变量(局部变量、方法定义参数、异常处理器参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,称为共享变量。所以,内存可见性是针对的共享变量

    既然堆是共享的,为什么在堆中会有内存不可见问题?

    这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为cpu访问缓存区比访问内存要快得多。

    线程之间的共享变量存在主内存中,每个线程都有一个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。

  1. 所有的共享变量都存在主内存中。
  2. 每个线程都保存了一份该线程使用到的共享变量的副本。
  3. 如果线程A与线程B之间要通信的话,必须经历下面2个步骤:
    1. 线程A将本地内存A中更新过的共享变量刷新到主内存中去。
    2. 线程B到主内存中去读取线程A之前已经更新过的共享变量。

所以,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。

根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取

所以线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存B去主内存中读取这个共享变量的新值,并拷贝到本地内存B中,最后线程B再读取本地内存B中的新值。

那么怎么知道这个共享变量的被其他线程更新了呢?这就是JMM的功劳了,也是JMM存在的必要性之一。JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证

Java中的volatile关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized关键字不仅保证可见性,同时也保证了原子性(互斥性)。在更底层,JMM通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员的方便理解,提出了happens-before,它更加的简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则以及这些规则的具体实现方法。

4. 重排序与happens-before

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。

什么指令重排序可以提高性能?

一个指令会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生,指令2不用等指令1执行完再执行。但是,流水线技术最害怕中断,恢复中断的代价是比较大的,指令重排就是减少中断的一种技术。

分析一下下面这个代码的执行情况:

a = b + c;
d = e - f ;

先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。综上所述,指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。

指令重排一般分为以下三种:

  • 编译器优化重排

    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令并行重排

    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

  • 内存系统重排

    由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

顺序一致性模型与JMM的保证

顺序一致性模型是一个理论参考模型,内存模型在设计的时候都会以顺序一致性内存模型作为参考。

数据竞争与顺序一致性

程序未正确同步的时候,可能存在数据竞争。若程序中包含了数据竞争,则运行的结果往往充满了不确定性

数据竞争:在一个线程中写一个变量,在另一个线程读同一个变量,并且写和读没有通过同步来排序。

Java内存模型(JMM)对于正确同步多线程程序的内存一致性做了以下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性。 即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。

这里的同步包括了使用volatilefinalsynchronized等关键字来实现多线程下的同步,需要正确使用。

顺序一致性模型

是一个理想化的理论参考模型,提供了极强的内存可见性保证。两大特性:

  • 一个线程中的所有操作必须按照程序的顺序(即Java代码的顺序)来执行。
  • 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见

有两个线程A和B并发执行,线程A程序中的顺序是A1->A2->A3,线程B:B1->B2->B3。

正确使用同步:A1->A2->A3->B1->B2->B3

没有使用同步:B1->A1->A2->B2->A3->B3

但是JMM没有这样的保证

在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;在这种情况下,当前线程和其他线程看到的执行顺序是可能不一样的。

JMM中同步程序的顺序一致性效果

在顺序一致性模型中,所有操作完全按照程序的顺序串行执行。但是JMM中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码“逃逸”到临界区之外,因为会破坏锁的内存语义)。

虽然线程A在临界区做了重排序,但是因为锁的特性,线程B无法观察到线程A在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。同时,JMM会在退出临界区和进入临界区做特殊的处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。由此可见,JMM的具体实现方针是:在不改变(正确同步的)程序执行结果的前提下,尽量为编译期和处理器的优化打开方便之门

JMM中未同步程序的顺序一致性效果

对于未同步的多线程程序,JMM只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值。为了实现这个安全性,JVM在堆上分配对象时,提前对内存空间清零。

JMM没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么JMM需要禁止大量的优化,影响性能。

未同步程序在JMM和顺序一致性内存模型中的执行特性有如下差异:

  • 顺序一致性保证单线程内的操作会按程序的顺序执行;JMM不保证单线程内的操作会按程序的顺序执行(重排序且不影响结果)。
  • 顺序一致性保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。(因为JMM不保证所有操作立即可见)
  • 顺序一致性模型保证对所有的内存读写操作都具有原子性,而JMM不保证对64位的long型和double型变量的写操作具有原子性。

happens-before

程序员需要JMM提供强的内存模型,编译器和处理器希望JMM提供弱的内存模型(可以做优化)。

对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),任意优化;而对于程序员,JMM提供了happens-before规则简单易懂,并且提供了足够强的内存可见性保证。

JMM使用happens-before的概念来定制两个操作之间的执行顺序(单、多线程)。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。定义如下:

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。

总之,如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程。

天然的happens-before关系

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

举例:

int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);

不难得出:

1> A happens-before B 
2> B happens-before C 
3> A happens-before C

重排序有两类,JMM对这两类重排序有不同的策略:

  • 会改变程序执行结果的重排序,比如 A -> C,JMM要求编译器和处理器都禁止这种重排序。
  • 不会改变程序执行结果的重排序,比如 A -> B,JMM对编译器和处理器不做要求,允许这种重排序。

5. voliate

内存可见性

内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。

重排序

为优化程序性能,对原有的指令执行顺序进行优化重新排序。可能发生在多个阶段:编译重排序、CPU重排序等。

happens-before规则

程序员写代码的时候遵循happens-before规则,JVM就能保证指令在多线程之间的顺序性符合程序员的预期。

volatile的内存语义

  • 保证变量的内存可见性
  • 禁止volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强的volatile内存语义”)

内存可见性

public class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;       // step 1
        flag = true; // step 2
    }

    public void reader() {
        if (flag) {                // step 3
            System.out.println(a); // step 4
        }
    }
}

volatile修饰的变量进行写操作(比如step 2)时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存。

volatile修饰的变量进行读操作(比如step 3)时,JMM会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。

在这一点上,volatile与锁具有相同的内存效果,volatile变量的写和锁的释放具有相同的内存语义,volatile变量的读和锁的获取具有相同的内存语义。

禁止重排序

为了提供一种比锁更轻量级的线程间的通信机制JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序。

编译器还好说,JVM是怎么还能限制处理器的重排序的呢?它是通过内存屏障来实现的。

什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。

注意这里的缓存主要指的是CPU缓存,如L1,L2等

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的JMM内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:

  • 在每个volatile写操作前插入一个StoreStore屏障;
  • 在每个volatile写操作后插入一个StoreLoad屏障;
  • 在每个volatile读操作后插入一个LoadLoad屏障;
  • 在每个volatile读操作后再插入一个LoadStore屏障。

再逐个解释一下这几个屏障。注:下述Load代表读操作,Store代表写操作

LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,这个屏障会把Store1强制刷新到内存,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

再介绍一下volatile与普通变量的重排序规则:

  1. 如果第一个操作是volatile读,那无论第二个操作是什么,都不能重排序;
  2. 如果第二个操作是volatile写,那无论第一个操作是什么,都不能重排序;
  3. 如果第一个操作是volatile写,第二个操作是volatile读,那不能重排序。

下列情况:第一个操作是普通变量读,第二个操作是volatile变量读,那是可以重排序的:

// 声明变量
int a = 0; // 声明普通变量
volatile boolean flag = false; // 声明volatile变量

// 以下两个变量的读操作是可以重排序的
int i = a; // 普通变量读
boolean j = flag; // volatile变量读

volatile的用途

在保证内存可见性这一点上,volatile有着与锁相同的内存语义,所以可以作为一个“轻量级”的锁来使用。但由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比volatile更强大;在性能上,volatile更有优势

在禁止重排序这一点上,volatile也是非常有用的。比如我们熟悉的单例模式,其中有一种实现方式是“双重锁检查”,比如这样的代码:

public class Singleton {

    private static Singleton instance; // 不使用volatile关键字

    // 双重锁检验
    public static Singleton getInstance() {
        if (instance == null) { // 第7行
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton(); // 第10行
                }
            }
        }
        return instance;
    }
}

如果这里的变量声明不使用volatile关键字,是可能会发生错误的。它可能会被重排序:

instance = new Singleton(); // 第10行

// 可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc
2 ctorInstanc(memory) //初始化对象
3 s=memory //设置s指向刚分配的地址

// 上述三个步骤可能会被重排序为 1-3-2,也就是:
1 memory=allocate();// 分配内存 相当于c的malloc
3 s=memory //设置s指向刚分配的地址
2 ctorInstanc(memory) //初始化对象

而一旦假设发生了这样的重排序,比如线程A在第10行执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候另一个线程B执行到了第7行,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!

所以JSR-133对volatile做了增强后,volatile的禁止重排序功能还是非常有用的。

6. java 锁

​ Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。(本文中的源码来自JDK 8)、使用场景进行举例,为读者介绍主流锁的知识点,以及不同的锁的适用场景。

​ Java中往往是按照是否含有某一特性来定义锁,我们通过特性将锁进行分组归类,再使用对比的方式进行介绍,帮助大家更快捷的理解相关知识。下面给出本文内容的总体分类目录:
请添加图片描述

乐观锁 VS 悲观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。

对于同一个数据进行并发操作,悲观锁认为一定有其他线程来同步修改这个数据,因此在获取数据时会先加锁,确定数据不会被别的线程修改。java中,synchronized关键字和Lock的实现类都是悲观锁。

而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
请添加图片描述

根据从上面的概念描述我们可以发现:

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。

  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

    光说概念有些抽象,我们来看下乐观锁和悲观锁的调用方式示例:

    请添加图片描述

通过调用方式示例,我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?我们通过介绍乐观锁的主要实现方式 “CAS” 的技术原理来为大家解惑。

CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。

synchronized

JAVA虚拟机给每个对象和class字节码文件都设置了一个监控器Monitor,用于检测并发代码的重入。同时,Object类中提供notify和wait方法来对Monitor中的线程进行控制。

sync锁是一个可重入的非公平独占锁。sync加锁(此处特指重量级锁)会去获取obj的Monitor,如果Monitor已经被其他线程获取,那么当前线程会进入Entry Set。等待其他线程释放obj的Monitor。

而这里的Monitor可以是类.class的Monitor,也可以是**当前对象(this)**Monitor。

sync可以指定使用的monitor对象,修饰静态方法时默认为class,修饰普通方法时为this。

  • 对象锁仅锁当前对象,不同对象之间不会互斥
  • 静态方法上的锁是类锁,非静态方法上的锁是对象锁
  • 所有静态同步方法互斥,无论是通过类调用还是对象调用
  • 静态方法上加synchronized与在非静态方法内加synchronized(Xxx.class)效果一致,都是类锁

Java类只有一个Class对象(可以有多个实例对象,多个实例共享这个Class对象),而Class对象也是特殊的Java对象。所以我们常说的类锁,其实就是Class对象的锁。

一个对象其实有四种锁状态,它们级别由低到高依次是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

总结锁的升级流程

每一个线程在准备获取共享资源时: 第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。

第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。

第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。

第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。

第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

各种锁的优缺点对比

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗CPU。追求响应时间。同步块执行速度非常快。
重量级锁线程竞争不使用自旋,不会消耗CPU。线程阻塞,响应时间缓慢。追求吞吐量。同步块执行时间较长。

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。

偏向锁

偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能。

大白话就是对锁置个变量,如果发现为true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为false,代表存在其他线程竞争资源,那么就会走后面的流程。

轻量级锁

多个线程在不同时段获取同一把锁,没有竞争也就没有线程阻塞。JVM采用轻量级锁来避免线程的阻塞与唤醒。

JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word复制到自己的Displaced Mark Word里面。

然后线程尝试用CAS将锁的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

自旋:不断尝试去获取锁,一般用循环来实现。

自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。

但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁

重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现的,在jvm层面封装成了monitor机制,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。

synchronized 锁机制在 Java 虚拟机中的同步是基于进入和退出监视器锁对象 monitor 实现的(无论是显示同步还是隐式同步都是如此),每个对象的对象头都关联着一个 monitor 对象,当一个 monitor 被某个线程持有后,它便处于锁定状态。

在 HotSpot 虚拟机中,monitor 是由 ObjectMonitor 实现的,每个等待锁的线程都会被封装成 ObjectWaiter 对象。

ObjectMonitor 中有两个集合,WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表 ,owner 区域指向持有 ObjectMonitor 对象的线程。当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合尝试获取 moniter,当线程获取到对象的 monitor 后进入 _Owner 区域并把 _owner 变量设置为当前线程,同时 monitor 中的计数器 count 加1;若线程调用 wait() 方法,将释放当前持有的 monitor,count自减1,owner 变量恢复为 null,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor 并复位变量的值,以便其他线程获取 monitor。如下图所示:

请添加图片描述

前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:

Monitor的大体结构如下:
Contention List:所有请求锁的线程将被首先放置到该竞争队列
Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
Owner:获得锁的线程称为Owner
!Owner:释放锁的线程

需要注意的是,当调用一个锁对象的waitnotify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

CAS

CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

判断V是否等于E,如果等于,将V的值设置为N;如果不等,则当前线程放弃更新,什么都不做。

所以这里的预期值E本质上指的是“旧值”

CAS是一种原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性

当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

Java实现CAS的原理 - Unsafe类

在Java中,如果一个方法是native的,那Java就不负责具体实现它,而是交给底层的JVM使用c或者c++去实现。

有一个Unsafe类,它在sun.misc包中。它里面是一些native方法,其中就有几个关于CAS的:

boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);

当然,他们都是public native的。

Unsafe中对CAS的实现是C++写的,它的具体实现和操作系统、CPU都有关系。

当然,Unsafe类里面还有其它方法用于不同的用途。比如支持线程挂起和恢复的parkunpark, LockSupport类底层就是调用了这两个方法。还有支持反射操作的allocateInstance()方法。

原子操作-AtomicInteger类源码简析

Unsafe类的几个支持CAS的方法。那Java具体是如何使用这几个方法来实现原子操作的呢?

JDK提供了一些用于原子操作的类,在java.util.concurrent.atomic包下面。

AtomicBoolean
AtomicInteger
AtomicReference ...

从名字就可以看得出来这些类大概的用途:

  • 原子更新基本类型
  • 原子更新数组
  • 原子更新引用
  • 原子更新字段(属性)

这里我们以AtomicInteger类的getAndAdd(int delta)方法为例,来看看Java是如何实现原子操作的。

先看看这个方法的源码:

public final int getAndAdd(int delta) {
    return U.getAndAddInt(this, VALUE, delta);
}

这里的U其实就是一个Unsafe对象:

private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();

所以其实AtomicInteger类的getAndAdd(int delta)方法是调用Unsafe类的方法来实现的:

@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!weakCompareAndSetInt(o, offset, v, v + delta));
    return v;
}

我们来一步步解析这段源码。首先,对象othis,也就是一个AtomicInteger对象。然后offset是一个常量VALUE。这个常量是在AtomicInteger类中声明的:

private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");

同样是调用的Unsafe的方法。从方法名字上来看,是得到了一个对象字段偏移量。

用于获取某个字段相对Java对象的“起始地址”的偏移量。

一个java对象可以看成是一段内存,各个字段都得按照一定的顺序放在这段内存里,同时考虑到对齐要求,可能这些字段不是连续放置的,

用这个方法能准确地告诉你某个字段相对于对象的起始内存地址的字节偏移量,因为是相对偏移量,所以它其实跟某个具体对象又没什么太大关系,跟class的定义和虚拟机的内存模型的实现细节更相关。

继续看源码。前面我们讲到,CAS是“无锁”的基础,它允许更新失败。所以经常会与while循环搭配,在失败后不断去重试。这里声明了一个v,也就是要返回的值。从getAndAddInt来看,它返回的应该是原来的值,而新的值的v + delta。这里使用的是do-while循环。这种循环不多见,它的目的是保证循环体内的语句至少会被执行一遍。这样才能保证return 的值v是我们期望的值。

循环体的条件是一个CAS方法:

public final boolean weakCompareAndSetInt(Object o, long offset,
                                          int expected,
                                          int x) {
    return compareAndSetInt(o, offset, expected, x);
}

public final native boolean compareAndSetInt(Object o, long offset,
                                             int expected,
                                             int x);

可以看到,最终其实是调用的我们之前说到了CAS native方法。那为什么要经过一层weakCompareAndSetInt呢?从JDK源码上看不出来什么。

简单来说,weakCompareAndSet操作仅保留了volatile自身变量的特性,而除去了happens-before规则带来的内存语义。也就是说,weakCompareAndSet**无法保证处理操作目标的volatile变量外的其他变量的执行顺序( 编译器和处理器为了优化程序性能而对指令序列进行重新排序 ),同时也无法保证这些变量的可见性。**这在一定程度上可以提高性能。

再回到循环条件上来,可以看到它是在不断尝试去用CAS更新。如果更新失败,就继续重试。那为什么要把获取“旧值”v的操作放到循环体内呢?其实这也很好理解。前面我们说了,CAS如果旧值V不等于预期值E,它就会更新失败。说明旧的值发生了变化。那我们当然需要返回的是被其他线程改变之后的旧值了,因此放在了do循环体内。

CAS实现原子操作的三大问题

ABA问题

所谓ABA问题,就是一个值原来是A,变成了B,又变回了A。这个时候使用CAS是检查不出变化的,但实际上却被更新了两次。

ABA问题的解决思路是在变量前面追加上版本号或者时间戳。从JDK 1.5开始,JDK的atomic包里提供了一个类AtomicStampedReference类来解决ABA问题。

这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用CAS设置为新的值和标志。

循环时间长开销大

CAS多与自旋结合。如果自旋CAS长时间不成功,会占用大量的CPU资源。

解决思路是让JVM支持处理器提供的pause指令

pause指令能让自旋失败时cpu睡眠一小段时间再继续自旋,从而使得读操作的频率低很多,为解决内存顺序冲突而导致的CPU流水线重排的代价也会小很多。

只能保证一个共享变量的原子操作

这个问题你可能已经知道怎么解决了。有两种解决方案:

使用JDK 1.5开始就提供的AtomicReference类保证对象之间的原子性,把多个变量放到一个对象里面进行CAS操作;

使用锁。锁内的临界区代码可以保证只有当前线程能操作。

7. JUC 使用

JUC的结构

请添加图片描述

Lokcs & Condition

抽象类AQS/AQLS/AOS

AQS(AbstractQueuedSynchronizer),提供了一个“队列同步器”的基本功能实现。而AQS里面的“资源”是用一个int类型的数据来表示的。

AQLS(AbstractQueuedLongSynchronizer),只是把资源的类型变成了long类型。

AOS(AbstractOwnableSynchronizer)是AQS和AQLS都继承的一个类。表示锁与持有者之间的关系

接口Condition/Lock/ReadWriteLock

juc.locks包下共有三个接口:ConditionLockReadWriteLock。其中,Lock和ReadWriteLock从名字就可以看得出来,分别是锁和读写锁的意思。Lock接口里面有一些获取锁和释放锁的方法声明,而ReadWriteLock里面只有两个方法,分别返回“读锁”和“写锁”:

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}
  • void lock():获取锁,如果锁不可用,则出于线程调度的目的,当前线程将被禁用,并且在获取锁之前处于休眠状态。
Lock lock = ...;
lock.lock();
try{

    //处理任务
    
}catch(Exception ex){

}finally{
    lock.unlock();   //释放锁
}
  • boolean tryLock():如果锁可用立即返回true,如果锁不可用立即返回false;
  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException:如果锁可用,则此方法立即返回true。 如果该锁不可用,则当前线程将出于线程调度目的而被禁用并处于休眠状态,直到发生以下三种情况之一为止:①当前线程获取到该锁;②当前线程被其他线程中断,并且支持中断获取锁;③经过指定的等待时间如果获得了锁,则返回true,没获取到锁返回false。
Lock lock = ...;

if(lock.tryLock()) {
     try{
         //处理任务
     }catch(Exception ex){

     }finally{
         lock.unlock();   //释放锁
     } 
}else {
//如果不能获取锁,则直接做其他事情
}
  • void unlock():释放锁。释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

Lock接口中有一个方法是可以获得一个Condition:

Condition newCondition();

每个对象都可以用继承自Objectwait/notify方法来实现等待/通知机制。而Condition接口也提供了类似Object监视器的方法,通过与Lock配合来实现等待/通知模式。

那为什么既然有Object的监视器方法了,还要用Condition呢?这里有一个二者简单的对比:

对比项Object监视器Condition
前置条件获取对象的锁调用Lock.lock获取锁,调用Lock.newCondition获取Condition对象
调用方式直接调用,比如object.notify()直接调用,比如condition.await()
等待队列的个数一个多个
当前线程释放锁进入等待状态支持支持
当前线程释放锁进入等待状态,在等待状态中不中断不支持支持
当前线程释放锁并进入超时等待状态支持支持
当前线程释放锁并进入等待状态直到将来的某个时间不支持支持
唤醒等待队列中的一个线程支持支持
唤醒等待队列中的全部线程支持支持

Condition和Object的wait/notify基本相似。其中,Condition的await方法对应的是Object的wait方法,而Condition的signal/signalAll方法则对应Object的notify/notifyAll()。但Condition类似于Object的等待/通知机制的加强版。我们来看看主要的方法:

方法名称描述
await()当前线程进入等待状态直到被通知(signal)或者中断;
当前线程进入运行状态并从await()方法返回的场景包括:(1)其他线程调用相同Condition对象的signal/signalAll方法,并且当前线程被唤醒;(2)其他线程调用interrupt方法中断当前线程;
awaitUninterruptibly()当前线程进入等待状态直到被通知,在此过程中对中断信号不敏感,不支持中断当前线程
awaitNanos(long)当前线程进入等待状态,直到被通知、中断或者超时。如果返回值小于等于0,可以认定就是超时了
awaitUntil(Date)当前线程进入等待状态,直到被通知、中断或者超时。如果没到指定时间被通知,则返回true,否则返回false
signal()唤醒一个等待在Condition上的线程,被唤醒的线程在方法返回前必须获得与Condition对象关联的锁
signalAll()唤醒所有等待在Condition上的线程,能够从await()等方法返回的线程必须先获得与Condition对象关联的锁

调用Condition的await()和signal()方法,都必须在lock保护之内,就是说必须在lock.lock()和lock.unlock之间才可以使用,因为内部会做释放锁的操作,如果不是在lock和unlock之间使用,会报错java.lang.IllegalMonitorStateException

ReentrantLock

ReentrantLock是一个非抽象类,它是Lock接口的JDK默认实现,实现了锁的基本功能。从名字上看,它是一个”可重入“锁,从源码上看,它内部有一个抽象类Sync,是继承了AQS,自己实现的一个同步器。同时,ReentrantLock内部有两个非抽象类NonfairSyncFairSync,它们都继承了Sync。从名字上看得出,分别是”非公平同步器“和”公平同步器“的意思。这意味着ReentrantLock可以支持”公平锁“和”非公平锁“。

通过看这两个同步器的源码可以发现,它们的实现都是”独占“的。都调用了AOS的setExclusiveOwnerThread方法,所以ReentrantLock的锁是”独占“的,也就是说,它的锁都是”排他锁“,不能共享。

在ReentrantLock的构造方法里,可以传入一个boolean类型的参数,来指定它是否是一个公平锁,默认情况下是非公平的。这个参数一旦实例化后就不能修改,只能通过isFair()方法来查看。

ReentrantReadWriteLock

这个类也是一个非抽象类,它是ReadWriteLock接口的JDK默认实现。它与ReentrantLock的功能类似,同样是可重入的,支持非公平锁和公平锁。不同的是,它还支持”读写锁“。

StampedLock

tampedLock类是在Java 8 才发布,有人号称它为锁的性能之王。它没有实现Lock接口和ReadWriteLock接口,但它其实是实现了“读写锁”的功能,并且性能比ReentrantReadWriteLock更高。StampedLock还把读锁分为了“乐观读锁”和“悲观读锁”两种。

前面提到了ReentrantReadWriteLock会发生“写饥饿”的现象,但StampedLock不会。它是怎么做到的呢?它的核心思想在于,在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作。这种模式也就是典型的无锁编程思想,和CAS自旋的思想一样。这种操作方式决定了StampedLock在读线程非常多而写线程非常少的场景下非常适用,同时还避免了写饥饿情况的发生。

LockSupport

LockSupport是一个线程工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,也可以在任意位置唤醒

public class LockS {
    public static class MyThread extends Thread{
        @Override
        public void run() {
            System.out.println(getName() + "进入线程");
            //LockSupport.park();
            //传入blocker,当线程被阻塞时,jstack会打印输出具体阻塞对象的信息,方便错误排查
            LoclSupport.park(this);
            System.out.println("t1 线程运行结束");
        }
    }

    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        t1.start();
        System.out.println("t1启动,但在内部执行了park");
        LockSupport.unpark(t1);
        System.out.println("LockSupport进行了unpart");
    }
}

我们定义一个线程,但是在内部进行了park,因此需要unpark才能唤醒继续执行,不过上面,我们在MyThread进行的park,在main线程进行的unpark。与wait/notify的区别。

(1)wait和notify都是Object中的方法,在调用这两个方法前必须先获得锁对象,但是park不需要获取某个对象的锁就可以锁住线程。

(2)notify只能随机选择一个线程唤醒,无法唤醒指定的线程,unpark却可以唤醒一个指定的线程。

并发容器

同步容器与并发容器

java.util包下提供了一些容器类,而Vector和Hashtable是线程安全的容器类,但是这些容器实现同步的方式是通过对方法加锁(sychronized)方式实现的,这样读写均需要锁操作,导致性能低下。而即使是Vector这样线程安全的类,在面对多线程下的复合操作的时候也是需要通过客户端加锁的方式保证原子性。

并发Queue

JDK并没有提供线程安全的List类,因为对List来说,很难去开发一个通用并且没有并发瓶颈的线程安全的List。因为即使简单的读操作,拿contains() 这样一个操作来说,很难想到搜索的时候如何避免锁住整个list。所以退一步,JDK提供了对队列和双端队列的线程安全的类:ConcurrentLinkedQueue和ConcurrentLinkedDeque。因为队列相对于List来说,有更多的限制。这两个类是使用CAS来实现线程安全的。

  • BlockingQueue
    • ArrayBlockingQueue
    • PriorityBlockingQueue
    • SynchronousQueue
    • DelayQueue
    • LinkedBlockingQueue
    • BlockingDeque
      • LinkedBlockingDeque
    • TransferQueue
      • LinkedTransferQueue
  • ConcurrentLinkedDeque
  • ConcurrentLinkedQueue

ConcurrentHashMap类

ConcurrentHashMap同HashMap一样也是基于散列表的map,但是它提供了一种与Hashtable完全不同的加锁策略,提供更高效的并发性和伸缩性。JDK 1.8中优化:

  • 同HashMap一样,链表也会在长度达到8的时候转化为红黑树,这样可以提升大量冲突时候的查询效率
  • 以某个位置的头结点(链表的头结点或红黑树的root结点)为锁,配合自旋+CAS避免不必要的锁开销

什么是CopyOnWrite容器

CopyOnWrite容器即写时复制的容器(写入时复制思想实现读写分离),当我们往一个容器中添加元素的时候,将当前容器进行copy,复制出来一个新的容器,然后向新容器中添加新的元素,最后将原容器的引用指向新容器。

CopyOnWriteArrayList

优点

  • 无需同步,读性能高,用于“读多写少”的并发场景且该场景下进行遍历时不抛异常

缺点

  • 写操作触发拷贝,内存压力大,可能多次引起FullGC
  • 读写分离在新老容器,读出数据可能是旧数据,数据一致性问题
并发工具类

Semaphore

用来控制同时访问特定资源,类似许可证

void acquire()

void acquire(int permits ) 获取n个许可,阻塞

void release() 释放一个许可

CountDownLatch

同步工具类,允许一个或者多个线程等待,知道其他线程操作执行完成

CountDownLatch cnt=new CountDownLatch(2)

cnt.await Thread 1

cnt.countDown() Thread 2

cnt.countDown() Thread 3

Thread 1 开始执行

CyclicBarrier

初始化一个规定数目和任务,计算调用CyclicBarrier.await() 进入等待的线程数,当到达规定数目时,所有线程和任务被唤醒执行

Phaser

这个是“移相器,相位器”的意思,CyclicBarrier可以发现它在构造方法里传入“任务总量”parties之后,便不能修改,并且每次调用await()方法也只能消耗一个parties计数。但Phaser可以动态地调整任务总量!Phaser可以设置层级关系,父子结构,可以分阶段完成任务。

Exchanger

Exchanger类用于两个线程交换数据。它支持泛型,也就是说你可以在两个线程之间不断传送任何数据。

线程池

forkjoin

用来执行分治任务。主题思想是将大任务分解为小任务,然后继续将小任务分解,直至能够直接解决为止,然后再依次将任务的结果合并。任务继承特定的Task类

8、java 线程休眠原语

在java中,可以通过4种方式让线程进入休眠状态,分别是 Thread.sleep、Object.wait、condition.await、LockSupport.park,今天就来研究这几个方法的区别。

condition.await 底层也是调用 LockSupport.park、LockSupport.unpark, 所以只有三种,对比如下:

请添加图片描述

9、JUC 原理

Java中的管程模型

管理共享变量以及对共享变量的操作过程,使其支持并发。对应的英文是Monitor,Java中通常被直译为监视器,操作系统中一般翻译为“管程”。

java中的Lock 和 synchronized 都是此模型在原理层面基本是一致的,只是实现方式不同。

在并发编程中,有两大核心问题:一是互斥,即同一时刻只允许一个线程访问共享资源;二是同步,即线程之间如何通信、协作。对于这两个问题,管程都可以解决。

互斥很好理解,但同步可能就不那么好理解了,同步在不同的场景也有不同的含义,在并发编程中的同步则指的是线程之间的通信和协作。最简单的例子就是生产者和消费者,如果没有商品,消费者线程如何通知生产者线程进行生产,生产商品后,生产者线程如何通知消费者线程来消费。

如何解决互斥

将共享变量以及对共享变量的操作 统一封装起来,如下图,多个线程想要访问共享变量queue,只能通过管程提供的enq()和deq()方法实现,这两个方法保持互斥性,且只允许一个线程进入管程。管程的模型和面向对象模型的契合度很高,这也可功能是Java一开始选择管程的原因(JDK5增加了信号量),互斥锁背后的模型其实就是它。

请添加图片描述

如何解决同步

在管程模型中,共享变量和对共享变量的操作是封装起来的,图中最外层的框代表着封装,框外边的入口等待队列,当多个线程试图进入管程内部时,只允许一个线程进入,其他线程在入口等待队列中等待,相当于多个线程同时访问临界区,只有一个线程拿到锁进入临界区,其余线程在等待区中等待,等待的时候线程状态是阻塞的。

管程中还引入了条件变量的概念,而且每个条件变量都有一个等待队列,如下图所示,管程通过引入“条件变量”和“等待队列”来解决线程同步的问题。

结合上面提到的生产者消费者的例子,商品库存为空,或者库存为满,都是条件变量,如果库存为空,那么消费者线程会调用nofity()唤醒生产者线程,并且自己调用wait()进入“库存为空”这个条件变量的等待队列中。

同理,生产者线程会唤醒消费者线程,自己调用wait()进入“库存为满”这个条件变量的等待队列中,被唤醒后会到入口等待队列中重新排队获取锁。这样就能解决线程之间的通信协作。

请添加图片描述

管程发展史上出现的三种模型

Hasen模型:将notify()放到代码最后,当前线程执行完再去唤醒另一个线程。

Hoare模型:中断当前线程,唤醒另一个线程执行,等那个线程执行完了,再唤醒当前线程。相比Hasen模型多了一次唤醒操作。

MESA模型:当前线程T1唤醒其他线程T2,T1继续执行,T2并不立即执行,而是从条件队列进到入口等待队列中,这样没有多余的唤醒操作,notify也不用放最后,但是会有一个问题,T2再次执行的时候,曾经满足的条件,现在已经不满足了,所以需要循环方式校验条件变量。

while(条件变量){
    wait();
}

https://segmentfault.com/a/1190000021329153

AQS原理

AQS源码解析 - 知乎 (zhihu.com)

通过CAS 修改 state的值,通过 CAS进行Node 首尾属性操作,将所有需要并发控制的地方用cas操作,实现了lock 的功能

还提供了公平非公平策略,重入策略,是通过入队前是否知己竞争等方式实现的,数据CAS操作前后的逻辑包装

10、JUC高级应用

实现先进先出非重入锁

LockSupport是用来创建锁和其他同步类的基本线程阻塞原语。

LockSupport 提供park()和unpark()方法实现阻塞线程和解除线程阻塞,LockSupport和每个使用它的线程都与一个许可(permit)关联。permit相当于1,0的开关,默认是0,调用一次unpark就加1变成1,调用一次park会消费permit, 也就是将1变成0,同时park立即返回。再次调用park会变成block(因为permit为0了,会阻塞在这里,直到permit变为1), 这时调用unpark会把permit置为1。每个线程都有一个相关的permit, permit最多只有一个,重复调用unpark也不会积累。

//看一个Java docs中的示例用法:一个先进先出非重入锁类的框架
public class FIFOLock {

    private final AtomicBoolean locked =new AtomicBoolean(false);

    private final Queue<Thread> waitQueue = new LinkedBlockingQueue();

    public void  lock() {
        boolean wasInterrupted = false;
        Thread currentThread = Thread.currentThread();
        waitQueue.add(currentThread);
        // Block while not first in queue or cannot acquire lock
        while (waitQueue.peek() != currentThread
                || !locked.compareAndSet(false, true)) {
            LockSupport.park(FIFOLock.class);
            System.out.println(Integer.toHexString(FIFOLock.class.hashCode()));
            System.out.println(System.identityHashCode(FIFOLock.class));
            if (Thread.interrupted()) {
                // ignore interrupts while waiting,park 会响应中断,调用interrupted后会改变中断状态,所以后续需要重新设置
                wasInterrupted = true;
            }
        }
        waitQueue.remove();
        if(wasInterrupted){
            currentThread.interrupt();   // reassert interrupt status on exit
        }
    }

    public void unlock(){
        locked.set(false);
        LockSupport.unpark(waitQueue.peek());
    }


}
ArrayBlockqueue源码

ArrayBlockingQueue 也是在具体操作array前进行 lock 操作保证线程安全。

实现 LockHashSet
public class LockHashSet<E> extends HashSet {
    ReentrantLock lock = new ReentrantLock();
    
    @Override
    public boolean add(Object o) {
        lock.lock();
        try{
            return super.add(o);
        }finally {
           lock.unlock();
        }

    }
    
    @Override
    public boolean remove(Object o) {
        lock.lock();
        try{
            return super.remove(o);
        }finally {
            lock.unlock();
        }

    }
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值