1 同步机制
参考书籍:Java核心技术-并发-同步
在大多数实际的多线程应用中,多个线程需要对同一数据进行存取,如多个线程同时买票。如果不加控制,则会出现并发问题,而线程的同步机制就是解决多线程争抢同步资源的线程安全问题的。如果向一个变量写入值,而这个变量接下来可能会被另一个线程读取,或者从一个变量读值 ,而这个变量可能是之前被另一个线程写入的,此时必须使用同步。
一言以概之:同步机制解决的就是多个线程操作同一个数据的问题。
多线程的同步机制要解决的如下问题及其解决方案:
- 多个线程如何同步数据(使用同步监视器即锁、atomic、volatile)
- 多个线程如何互斥执行(使用同步监视器即锁、atomic等)
多个线程如何通信(使用同步监视器即锁)
线程同步安全小总结
工作内存与主内存同步延迟现象导致的可见性问题:可通过synchronized或volatile关键字解决,他们都可以使一个线程修改后的变量立即对其它线程可见。
对于指令重排导致的可见性问题和有序性问题:可使用volatile关键字解决,因为volatile关键字的另一个作用就是禁止重排序优化。
1 AbstractQueuedSynchronizer
AQS概述
参考:AQS体系、AQS体系Update
AQS是用来构建锁或者其它同步器组件的重量级基础框架,其是整个JUC体系的基石。队列CLH(FIFO):加锁会导致阻塞、有阻塞就需要排队,实现排队必然需要队列。 该框架通过内置的CLH(FIFO)队列的变种来完成资源获取线程的排队工作:将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。CLH:Craig、Landin and Hagersten 队列,是一个单向链表;AQS中的队列是CLH变体的虚拟双向队列FIFO。
- 线程Node:AQS框架会将每条将要去抢占资源的线程封装成一个Node节点来实现锁的分配,该线程Node构成了队列CLH(FIFO)。
- 锁的状态Status:有一个int类变量表示持有锁的状态(private volatile int state),通过CAS、自旋和LockSupport.park()完成对该status值的修改(0表示没有人占用锁,此时自由状态可以去抢占锁,1表示有人占用窗口,此时阻塞,其他线程需要进入CLH里面排队等候)。
总结:AQS=state变量+CLH双端队列(队列每个元素即为Node线程结点)。
- 锁和同步器
锁:面向锁的使用者(其定义了程序员和锁交互的使用层API,隐藏了实现细节,程序员直接调用即可)。
同步器:面向锁的实现者(比如Java并发大神Douglee,提出统一规范并简化了锁的实现,屏蔽了同步状态管理、阻塞线程排队和通知、唤醒机制等。)
AQS为什么是JUC内容中最重要的基石:AbstractQueuedSynchronizer位于java.util.concurrent.locks
包下,其定义了同步器的约束和规范。
package java.util.concurrent.locks;
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
private volatile int state;
private transient volatile Node head;
private transient volatile Node tail;
static final class Node {
//node节点内容详见下方截图
}
}
AQS内部体系架构
从ReentrantLock开始解读AQS
代码开始 ```java //代码演示 public class AQSDemo { public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
//带入一个银行办理业务的案例来模拟我们的AQS如何进行线程的管理和通知唤醒机制:3个线程模拟3个来银行网点,受理窗口办理业务的顾客
//第一个顾客,第一个线程===》此时受理窗口没有任何人,A可以直接去办理
new Thread(() -> {
lock.lock();
try{
System.out.println("-----A thread come in");
try { TimeUnit.MINUTES.sleep(20); }catch (Exception e) {e.printStackTrace();}
}finally {
lock.unlock();
}
},"A").start();
//第二个顾客,第二个线程---》由于受理业务的窗口只有一个(只能一个线程持有锁),此时B只能等待,即进入候客区
new Thread(() -> {
lock.lock();
try{
System.out.println("-----B thread come in");
}finally {
lock.unlock();
}
},"B").start();
//第三个顾客,第三个线程---》由于受理业务的窗口只有一个(只能一个线程持有锁),此时C只能等待,即进入候客区
new Thread(() -> {
lock.lock();
try{
System.out.println("-----C thread come in");
}finally {
lock.unlock();
}
},"C").start();
} }
程序初始化时的概念图<br />![b70802fbaff7f48f2882b4a1c6c95673_c43af638d95a7c6d45219b9da17fad64.png](https://cdn.nlark.com/yuque/0/2022/png/696107/1645430171325-887d2ff3-d6d2-40c5-9f2b-2ab4e7c25946.png#clientId=uecf94866-30fa-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=402&id=ud22a68a9&margin=%5Bobject%20Object%5D&name=b70802fbaff7f48f2882b4a1c6c95673_c43af638d95a7c6d45219b9da17fad64.png&originHeight=442&originWidth=1041&originalType=binary&ratio=1&rotation=0&showTitle=false&size=183414&status=done&style=none&taskId=uc1a21f16-1a3f-45d9-8f16-0bfc2743a96&title=&width=946.3636158517573)
2. 启动程序,首先是运行线程A,ReentrantLock默认是选用非公平锁:线程A开始办理业务,此时锁的状态为state=0,Thread为null,将state=1并同步更新Thread=ThreadA
![0d39903e0d993f1b5a045eb1634268d3_096f574353f3965eed5996e8a6962f94.png](https://cdn.nlark.com/yuque/0/2022/png/696107/1645430519406-50958f5a-9acf-416d-ac20-72976717dc77.png#clientId=uecf94866-30fa-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=404&id=ubb19c16e&margin=%5Bobject%20Object%5D&name=0d39903e0d993f1b5a045eb1634268d3_096f574353f3965eed5996e8a6962f94.png&originHeight=444&originWidth=1045&originalType=binary&ratio=1&rotation=0&showTitle=false&size=194521&status=done&style=none&taskId=u443eac06-1fa6-4f2c-82d0-0feaa5cc7a3&title=&width=949.999979409305)
3. 线程B开始运行:其会抢占锁,发现锁已经被占用,则线程B加入等待队列CLH(FIFO)。队列里面共计2个结点:傀儡头节点,线程B节点。
```java
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
public final void acquire(int arg) {
if (!tryAcquire(arg) &&//线程B调用非公平锁的tryAcquire(), 最终返回false,加上!,也就是true,也就是还要执行下面两行语句
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();//如果抢锁失败,将自我阻塞
}
static void selfInterrupt() {
Thread.currentThread().interrupt();
}
}
note:双向链表中,第一个节点为傀儡节点(也叫哨兵节点),其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是从第二个节点开始的。
- 线程C开始工作,如法炮制的加入到等待队列中:队列的头节点指向傀儡节点,队列里面共计3个Node线程节点:傀儡节点、线程B结点、线程C结点。
- 线程A工作结束,调用unLock(),释放锁占用,state由1变为0,exclusiveOwnerThread由线程A变为null。
线程B被唤醒,即从原先LockSupport.park()的方法继续运行。B成功上位的概念图:
公平锁与非公平锁
可以明显看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。
hasQueuedPredecessors是公平锁加锁时判断等待队列中是否存在有效节点的方法,该方法导致致公平锁和非公平锁的差异如下:
- 公平锁:公平锁讲究先来先到,线程在获取锁时,如果这个锁的等待队列中已经有线程在等待,那么当前线程就会进入等待队列中
- 非公平锁:不管是否有等待队列,如果可以获取锁,则立刻占有锁对象。也就是说队列的第一个排队线程在unpark(),之后还是需要竞争锁(存在线程竞争的情况下)
总结
Java并发包中提供了锁的另一种实现Lock接口,它定义了锁的获取和释放基本操作。
队列同步器 AbstractQueuedSynchronizer 这个可以说是Java并发包中构建锁和各种安全容器实现的基石,比如 ReentrantLock、ReadWriteLock、CountDownLatch 等的实现中都少了AQS的身影。
在并发环境下,并发包中提供了实现了Lock接口的各种锁,他们依赖AQS同步器完成加锁解锁操作。而AQS的实现主要需要下面三个组件协调:
- 锁状态。我们要知道锁是不是被别的线程占有了,这个就是 state 的作用,它为 0 的时候代表没有线程占有锁,可以去争抢这个锁,用 CAS 将 state 设为 1,如果 CAS 成功,说明抢到了锁,这样其他线程就抢不到了,如果锁重入的话,state进行 +1 就可以,解锁就是减 1,直到 state 又变为 0,代表释放锁,所以 lock() 和 unlock() 必须要配对啊。然后唤醒同步队列中的第一个线程,让其来占有锁。
- 线程的阻塞和解除阻塞。AQS 中采用了 LockSupport.park(thread) 来挂起线程,用 unpark 来唤醒线程。
阻塞队列。因为争抢锁的线程可能很多,但是只能有一个线程拿到锁,其他的线程都必须等待,这个时候就需要一个 queue 来管理这些线程,AQS 用的是一个 FIFO 的队列,就是一个链表,每个 node 都持有后继节点的引用。
2 互斥同步:synchronized
synchronized锁
synchronized即为同步监视器,也称为锁
锁的是什么?任意对象都可以作为同步锁。所有对象都自动含有单一的锁(监视器)。
- 同步方法的锁的是:静态方法(类名.class)、非静态方法(this) 。
- 同步代码块锁的是:自己指定,很多时候也是指定为this或类名.class。
需要注意哪些点?
- 必须确保使用同一个资源的多个线程共用一把锁,这个非常重要,否则就无法保证共享资源的安全。
- 一个线程类中的所有静态方法共用同一把锁(类名.class),所有非静态方法共用同一把锁(this),同步代码块(指定需谨慎)。
什么时候会释放锁?
- 当前线程的同步方法、同步代码块执行结束。
- 当前线程在同步代码块、同步方法中遇到break、return终止了该代码块或该方法的继续执行。
- 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致异常结束。
- 当前线程在同步代码块、同步方法中执行了线程对象的wait()方法,当前线程暂停,并释放锁。
不会释放锁的操作?
- 线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法暂停当前线程的执行。
- 线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放锁(同步监视器)。应尽量避免使用suspend()和resume()来控制线程。
代码
class Windows1 implements Runnable{
private int ticket = 100;
//Object obj = new Object();
//Dog dog = new Dog();
@Override
public void run() {
while(true){
//使用另一种方式:synchronized (obj) 或 synchronized(dog)
//此时的this:唯一的windows1的对象
synchronized (this) {
if (ticket > 0) {
try{
Thread.sleep(100);
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":卖票,票号为: " + ticket);
ticket--;
} else {
break;
}
}
}
}
}
public class WindowsTest1 {
public static void main(String[] args) {
Windows1 w = new Windows1();
Thread t1 = new Thread(w);
Thread t2 = new Thread(w);
t1.setName("窗口1");
t2.setName("窗口2");
t1.start();
t2.start();
}
}
class Dog{
}
//失败的代码:如下代码起不到同步监视的效果,因为synchronized (this)监控的就是自己当前线程对象,并不是一个监视器对象。
Thread thread = new Thread() {
@Override
public void run() {
synchronized (this){
System.out.println("线程1");
Thread.sleep(30000);
}
}
};
Thread thread2 = new Thread() {
@Override
public void run() {
synchronized (this){
System.out.println("线程2");
}
}
};
synchronized锁的升级
参考文档:锁的优化与升级、大厂Mind、计算对象的对象头大小
锁升级优化的背景
java5以前,只有Synchronized。这个是操作系统级别的重量级锁操作,假如锁的竞争比较激烈的话,性能会下降:因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成。阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长。时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
Java6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。
扩展:为什么每一个对象都可以成为一个锁?
- Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自创建的时候就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor是在jvm底层实现的,底层代码是c++。本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,状态转换需要耗费很多的处理器时间成本非常高。
- Monitor(监视器锁)的本质是依赖于底层操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高。所以synchronized是Java语言中的一个重量级操作。
扩展(此处使用进程原理来类比举例):一个进程从运行态变成阻塞态是主动(程序通过陷入的方式将CPU调整为内核态,此时OS将得到CPU的使用权,其响应应用程序对于资源如I/O的请求)的行为,而从阻塞态变成就绪态是被动 的行为,需要其他相关进程的协助。
Monitor与java对象以及线程是如何关联?
- Monitor 被翻译为监视器或管程 ,每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针。
- 如果一个java对象被某个线程锁住,则该java对象的Mark Word字段中LockWord指向monitor的起始地址。
- Monitor的Owner字段会存放拥有相关联对象锁的线程id。(Monitor是操作系统层面提供的)
锁种类及升级步骤
Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,即锁的使用需要有个逐步升级的过程,不要一开始就直接上到重量级锁。
具体的升级策略:由对象头中的Mark Word根据锁的不同状态显示不同的标志位。先后从无锁->偏向锁->轻量级锁->重量级锁。
无锁
偏向锁
主要作用
当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁(偏向锁)。
核心概念
- Hotspot的作者经过研究发现,大多数情况下:在多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
技术实现
- 一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的Mark Word中将偏向锁修改状态位,同时还会有占用前54位来存储线程指针作为标识。若该线程再次访问同一个synchronized方法时,该线程只需去对象头的MarkWord 中去判断一下是否有偏向锁指向本身的ID,无需再进入Monitor去竞争对象了。
- 偏向锁的操作不用直接捅到操作系统,不涉及用户到内核转换,不必要直接升级为最高级锁。
- 我们以一个account对象的“对象头”为例:假如有一个线程执行到synchronized代码块的时候,JVM使用CAS操作把线程指针ID记录到Mark Word当中,并修改标偏向标示,标示当前线程就获得该锁。锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。
- 当该线程第二次到达同步代码块时会判断此时持有锁的线程是否还是自己(持有锁的线程ID也在对象头里),JVM通过account对象的Mark Word判断:如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁。以后每次同步,只需检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。假如不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候可能需要升级变为轻量级锁才能保证线程间公平竞争锁。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
- 结论:VM不用和操作系统协商设置Mutex(争取内核),它只需要记录下线程ID就标示自己获得了当前锁,不用操作系统接入。综述就是偏向锁:在没有其他线程竞争的时候,一直偏向偏心当前线程,当前线程可以一直执行。
偏向锁的撤销
偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销。撤销需要等待全局安全点(该时间点上没有字节码正在执行),同时需检查持有偏向锁的线程是否还在执行。假如线程A正在执行中,线程B进来抢夺锁,根据线程A所处的不同位置,分为两种情况:
- 假如线程A还正在执行 synchronized方法(处于同步块内),它还没有执行完其它线程来抢夺,该偏向锁会被取消掉并出现锁升级。此时轻量级锁由原持有偏向锁的线程持有,线程A继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁。
- 假如线程A已经执行完了synchronized方法(已退出了同步块),则将对象头设置成无锁状态并撤销偏向锁,重新偏向。如果此时A执行完成了,则B自动获得偏向锁,如果A还要继续用,两个线程竞争,谁竞争上了谁上岗(通过CAS)。
轻量级锁
主要作用和目的
本质就是自旋,有线程来参与锁的竞争,但是获取锁的冲突时间极短。目的:在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋再阻塞。
轻量级锁的获取
- 轻量级锁是为了在线程近乎交替执行同步块时提高性能
- 升级时机:当关闭偏向锁功能或多线程竞争偏向锁则会导致偏向锁升级为轻量级锁
- 假如线程A已经拿到偏向锁,这时线程B又来抢该对象的锁,其在争抢时发现对象头Mark Word中的线程ID不是线程B自己的线程ID而是线程A的,那线程B就会进行CAS操作希望能获得锁。此时线程B操作中有两种情况:
- 如果锁获取成功,直接替换Mark Word中的线程ID为B自己的ID(A → B),重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程”被”释放了锁),该锁会保持偏向锁状态,A线程视情况是Over还是继续抢夺,B线程上位。
- 如果锁获取失败,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁。
测试
//1 Maven前提
<!--JAVA object layout。官网:http://openjdk.java.net/projects/code-tools/jol/。定位:分析对象在JVM的大小和分布 -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
//2 JVM前提
-XX:-UseCompressedClassPointers//去掉自动开启压缩指针功能
-XX:-UseBiasedLocking//关闭偏向锁:关闭之后程序默认会直接进入轻量级锁状态
//3 代码
package generices;
import org.openjdk.jol.info.ClassLayout;
class Test {
public static final Object obj=new Object();
public static void main(String[] args) {
synchronized (obj){
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
//4 效果:具体结果详见下方截图
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 90 f2 17 03 (10010000 11110010 00010111 00000011) (51901072)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 1c 56 21 (00000000 00011100 01010110 00100001) (559291392)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 1c 56 21 (00000000 00011100 01010110 00100001) (559291392)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
轻量锁与偏向锁的区别和不同
- 争夺轻量级锁失败时,自旋尝试抢占锁
- 轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁
轻量级锁的升级
- java6之前:默认启用,默认情况下自旋的次数是10次,-XX:PreBlockSpin=10来修改或者自旋线程数超过cpu核数一半
Java6之后:自适应:自适应意味着自旋的次数不是固定不变的,而是根据同一个锁上一次自旋的时间和拥有锁线程的状态来决定
重锁
总结
锁升级的总结
synchronized锁升级过程总结:一句话,先自旋,不行再阻塞。实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的
- JDK1.6之前synchronized使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁
各种锁的总结
- 偏向锁:适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。如果是多并发的情况下,可使用
-XX:-UseBiasedLocking
来关闭偏向锁,那么程序在发生锁的时候就会自动从无锁->轻量级锁。 - 轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似),存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽
- 然会占用cpu资源但是相对比使用重量级锁还是更高效。
- 重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁
锁升级的扩展
锁消除:从JIT角度看相当于无视它,synchronized(o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,极端的说就是根本没有加这个锁对象的底层机器码。消除了锁的使用
**
* 锁消除
* 从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,
* 极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用
*/
public class LockClearUPDemo{
static Object objectLock = new Object();//正常的
public void m1(){
//锁消除,JIT会无视它,synchronized(对象锁)不存在了。不正常的
Object o = new Object();
synchronized (o){
System.out.println("-----hello LockClearUPDemo"+"\t"+o.hashCode()+"\t"+objectLock.hashCode());
}
}
public static void main(String[] args){
LockClearUPDemo demo = new LockClearUPDemo();
for (int i = 1; i <=10; i++) {
new Thread(() -> {
demo.m1();
},String.valueOf(i)).start();
}
}
}
锁粗话:假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能。
/**
* 锁粗化
* 假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,
* 加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能
*/
public class LockBigDemo{
static Object objectLock = new Object();
public static void main(String[] args){
new Thread(() -> {
synchronized (objectLock) {
System.out.println("11111");
}
synchronized (objectLock) {
System.out.println("22222");
}
synchronized (objectLock) {
System.out.println("33333");
}
},"a").start();
new Thread(() -> {
synchronized (objectLock) {
System.out.println("44444");
}
synchronized (objectLock) {
System.out.println("55555");
}
synchronized (objectLock) {
System.out.println("66666");
}
},"b").start();
}
}
2 同步互斥:Lock
Lock的锁机制
概览
- 从JDK 5.0开始,Java提供了更强大的线程同步机制,通过显式定义同步锁对象来实现同步,同步锁使用Lock对象充当。
java.util.concurrent.locks.Lock
接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。 ReentrantLock 类实现了Lock
,它拥有与synchronized相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁。- 如果同步代码有异常,要将unlock()写入finally语句块。
lock的lock()和unlock方法必须一一匹配,多了会抛出异常,少了锁会没有释放,其他地方要使用就会阻塞。其内部使用的是AbstractQueuedSynchronizer.lock/unLock,即使用的队列+CAS。
//案例1:多次释放锁
public static void main(String[] args) {
ReentrantReadWriteLock.ReadLock lock = new ReentrantReadWriteLock().readLock();
lock.lock();
lock.unlock();
lock.unlock();
}
//测试结果:一个lock,两个unlock,出现不匹配情况,则抛出异常
Exception in thread "main" java.lang.IllegalMonitorStateException: attempt to unlock read lock, not locked by current thread
at java.util.concurrent.locks.ReentrantReadWriteLock$Sync.unmatchedUnlockException(ReentrantReadWriteLock.java:444)
at java.util.concurrent.locks.ReentrantReadWriteLock$Sync.tryReleaseShared(ReentrantReadWriteLock.java:428)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.releaseShared(AbstractQueuedSynchronizer.java:1341)
at java.util.concurrent.locks.ReentrantReadWriteLock$ReadLock.unlock(ReentrantReadWriteLock.java:881)
at generices.Test.main(Test.java:14)
//案例2:lock()方法多于unlock()方法,即未及时unlock()
public static void main(String[] args) throws InterruptedException {
Lock lock = new ReentrantLock();
new Thread(() -> {
lock.lock();
System.out.println("xxxx");
}).start();
TimeUnit.SECONDS.sleep(1);
//线程2在线程1没释放lock锁之前会一直阻塞,即为死锁现象
new Thread(() -> {
lock.lock();
System.out.println("yyyy");
}).start();
}
//测试结果:线程2一直卡住无法执行接下来操作
Lock的锁类型一般集成在
java.util.concurrent.locks
包下面,lock接口源码 ```java package java.util.concurrent.locks; import java.util.concurrent.TimeUnit; /*
@since 1.5 */ public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }
![image.png](https://cdn.nlark.com/yuque/0/2022/png/696107/1644312579336-6145e268-2f7a-42b0-8ddf-97835852c287.png#clientId=ue08b176c-275e-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=296&id=ZpzRy&margin=%5Bobject%20Object%5D&name=image.png&originHeight=282&originWidth=301&originalType=binary&ratio=1&rotation=0&showTitle=false&size=12071&status=done&style=none&taskId=u22108c9f-c87b-408e-8447-f7084e39562&title=&width=315.6221618652344)<br />**代码**
```java
class Ecoco10Application {
private Lock lock=new ReentrantLock();
@Test
void TestThread() throws InterruptedException {
Thread thread = new Thread() {
@Override
public void run() {
try {
lock.lock();
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
finally {
System.out.println("End");
//一定要写在finally
lock.unlock();
}
}
};
Thread thread2 = new Thread() {
@Override
public void run() {
try{
lock.lock();
}catch (Exception ex){
}
finally {
//一定要写在finally
System.out.println(123);
lock.unlock();
}
}
};
thread.start();
thread2.start();
Thread.sleep(50000);
}
}
条件对象Condition
通常, 线程进人临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。在这一节里,我们介绍Java库中条件对象的实现。由于历史的原因,条件对象经常被称为条件变量 (conditional variable )。
条件对象Condition的应用场景:假如线程A需要往银行账户取钱,转账接口需要获取锁然后内部判断余额是否够。现在的问题是:当账户中没有足够的余额时,则应该让出当前线程,等待另一个线程向账户中注入了资金。 但是,这一线程刚刚获得了对lock锁对象排它性访问,因此别的线程没有进行存款操作的机会。 这就是为什么我们需要条件对象的原因(即典型的生产者-消费者问题)。public class MyLock {
static Lock lock = new ReentrantLock();
//转账业务
public void transfer(int from, int to, int amount) {
lock.lock();
try {
//如果账户余额不足,则阻塞
while (accounts[from] < amount) {
//wait
//transfer funds
}
} finally {
lock.unlock();
}
}
}
一个锁对象可以有一个或多个相关的条件对象,通常可以用newCondition()获得一个条件对象,并习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。当调用了condition.await()则当前线程会被阻塞, 并放弃锁,等到其他线程拿到锁并操作了同步资源后,再调用condition.signalAll()唤醒所有等待挂起的线程。
注意:调用signalAll()或signal()方法不会立即激活一个等待线程。它仅仅解除等待线程的阻塞,以便这些线程可以在当前线程退出同步方法之后(退出或调用await()方法), 通过竞争实现对对象的访问,而调用await()则是会立刻挂起阻塞当前线程的。
代码案例:使用lock+ lockobj.await+ lockobj.signal来实现
需求:消费者线程小于0个就停止消费,阻塞并唤醒生产者线程生产,否则一直消费;生产者线程大于10个就一直生产,否则阻塞并唤醒消费者线程进行消费。
class Ecoco10ApplicationTests {
private Object object = new Object();
private int count = 0;
@Test
void TestThread() throws InterruptedException {
//生产者线程
Thread threadProducer = new Thread() {
@SneakyThrows
@Override
public void run() {
while (true) {
synchronized (object) {
Thread.sleep(3000);
if (count == 0) {
count++;
System.out.println("线程1进行了生产" + count);
object.notify();//已经生产完了,通知被阻塞的消费线程
}
object.wait();//已经生产完了,阻塞当前线程并释放同步监视器
}
}
}
};
//消费者线程
Thread threadConsumer = new Thread() {
@SneakyThrows
@Override
public void run() {
while (true) {
synchronized (object) {
Thread.sleep(3000);
if (count > 0) {
count--;
System.out.println("线程2进行了消费" + count);
object.notify();//已经消费完了,通知被阻塞的生产线程
}
object.wait();//已经消费完了,阻塞当前线程并释放同步监视器
}
}
}
};
threadProducer.start();
threadConsumer.start();
Thread.sleep(50000);
}
}
可重入锁ReentrantLock
概述
可重入锁就是递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取到该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
也就是说:线程可以进入任何一个它已经拥有的锁所同步的代码块。ReentrantLock/Synchronized就是一个典型的可重入锁。可重入锁的最大作用就是避免死锁。
//ReentrantLock类源码
package java.util.concurrent.locks
public class ReentrantLock implements Lock, java.io.Serializable {
}
可重入锁的种类
synchronized修饰的隐式锁
简单的来说就是:在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁的。
机制:每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。 当执行monitorenter时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。
为什么任何一个对象都可以称为一个锁:对象头有持有该对象的线程ID。
@Test
void reEntryLockDemo() {
final Object objectLockA = new Object();
new Thread(() -> {
synchronized (objectLockA) {
System.out.println("-----外层调用");
synchronized (objectLockA) {
System.out.println("-----中层调用");
synchronized (objectLockA) {
System.out.println("-----内层调用");
}
}
}
}, "a").start();
}
Lock修饰的显示锁
@Test
void reEntryLockDemo() {
Lock lock = new ReentrantLock();
//线程1
new Thread(() -> {
lock.lock();
try {
System.out.println("----外层调用lock");
lock.lock();
try {
System.out.println("----内层调用lock");
} finally {
//正常情况,加锁几次就要解锁几次
//如果这里没有调用unlock()方法,则因为加锁次数和释放次数不一样,第二个线程就会始终无法获取到锁,导致一直在等待。
lock.unlock();
}
} finally {
//正常情况,加锁几次就要解锁几次
lock.unlock();
}
}, "a").start();
new Thread(() -> {
lock.lock();
try {
System.out.println("b thread----外层调用lock");
} finally {
lock.unlock();
}
}, "b").start();
}
非公平与公平锁ReentrantLock(true)
概述
公平锁:多个线程按照申请锁的顺序来获取锁,先来后到,先来先服务,就是公平的。实现机制为队列。优先是在业务场景上更公平。
非公平锁:多个线程获取锁的顺序,并不是按照申请锁的顺序,有可能申请的线程比先申请的线程优先获取锁。在高并发环境下,有可能造成优先级翻转,或者饥饿的线程(也就是某个线程一直得不到锁)。优点是性能更好。
如何理解非公平锁性能更好?
- 恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分的利用CPU的时间片,尽量减少CPU空闲状态时间。
- 如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省很多线程切换时间,吞吐量自然就上去了,对于synchronized而言,也是一种非公平锁。
如何使用
java.util.concurrent.locks.ReentrantLock在创建的时候可以传入一个参数,指定是否使用公平锁,默认为false,即非公平锁。
//创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
Lock lock = new ReentrantLock(true);
代码演示公平锁与非公平锁的区别
//非公平锁
class Ticket {
private int number = 30;
ReentrantLock lock = new ReentrantLock();
public void sale() {
lock.lock();
try {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第:\t" + (number--) + "\t 还剩下:" + number);
TimeUnit.SECONDS.sleep(1);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
public class SaleTicketDemo {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(() -> {
for (int i = 0; i < 35; i++)
ticket.sale();
}, "a").start();
new Thread(() -> {
for (int i = 0; i < 35; i++)
ticket.sale();
}, "b").start();
new Thread(() -> {
for (int i = 0; i < 35; i++)
ticket.sale();
}, "c").start();
}
}
//如上非公平锁效果
a卖出第: 10 还剩下:9
a卖出第: 9 还剩下:8
a卖出第: 8 还剩下:7
a卖出第: 7 还剩下:6
a卖出第: 6 还剩下:5
a卖出第: 5 还剩下:4
a卖出第: 4 还剩下:3
a卖出第: 3 还剩下:2
a卖出第: 2 还剩下:1
a卖出第: 1 还剩下:0
//变更为公平锁之后的效果,只需更改如下代码,其余的不变动:ReentrantLock lock = new ReentrantLock(true);
a卖出第: 10 还剩下:9
b卖出第: 9 还剩下:8
c卖出第: 8 还剩下:7
a卖出第: 7 还剩下:6
b卖出第: 6 还剩下:5
c卖出第: 5 还剩下:4
a卖出第: 4 还剩下:3
b卖出第: 3 还剩下:2
c卖出第: 2 还剩下:1
a卖出第: 1 还剩下:0
自旋锁MySpinLockDemo
概述
自旋锁:spinlock,是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,循环比较获取直到成功为止,没有类似于wait的阻塞。缺点是循环会消耗CPU,当不断自旋的线程越来越多的时候,会因为执行while循环不断的消耗CPU资源。
CAS比较并交换机制,底层使用的就是自旋,自旋就是多次尝试,多次访问,不会阻塞的状态就是自旋。
手写一个自旋锁
//手写一个自旋锁
class MySpinLock {
//原子引用,泛型为线程对象
static AtomicReference<Thread> threadAtomicReference = new AtomicReference<>();
//锁住线程
public static void lock() {
Thread thread = Thread.currentThread();
//CAS循环判断,如果期望的是null(即当前原子引用没有监听过对象),就设置为传入的线程对象(即监听传入的线程对象)。
//如果交换失败,即里面有监听对象了,即不为null了,则循环等待(即自旋)。
while (threadAtomicReference.compareAndSet(null, thread) == false) {
}
}
//解锁
public static void unLock() {
Thread thread = Thread.currentThread();
threadAtomicReference.compareAndSet(thread, null);
}
}
public class LockDemo {
public static void main(String[] args) {
//==============线程1==============//
Thread thread01 = new Thread() {
@Override
public void run() {
super.run();
MySpinLock.lock();
System.out.println("线程1拿到锁时间:" + System.currentTimeMillis());
try {
TimeUnit.SECONDS.sleep(5);
MySpinLock.unLock();
System.out.println("线程1释放锁时间:" + System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
thread01.setName("Thread-01");
thread01.start();
//==============线程2==============//
Thread thread02 = new Thread() {
@Override
public void run() {
super.run();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
MySpinLock.lock();
System.out.println("线程2拿到锁时间:" + System.currentTimeMillis());
MySpinLock.unLock();
}
};
thread02.setName("Thread02");
thread02.start();
}
}
//测试结果
线程1拿到锁时间:1644224689253
线程1释放锁时间:1644224694268
线程2拿到锁时间:1644224694268
读写锁ReentrantReadWriteLock
概念
独占锁:指该锁一次只能被一个线程所持有。对ReentrantLock和Synchronized而言都是独占锁
共享锁(读写锁):指该锁可以被多个线程锁持有。如ReentrantReadWriteLock,其读锁是共享,而写锁是独占:写的时候只能一个人写并且在写的时候,其他人也不能读,但是读的时候,可以多个人同时读,但是其他人不能写。
即:读读:能共存;读写:不能共存;写写:不能共存;写读:不能共存。
一句话总结:读写锁并不是真正意义上的读写分离,一个共享资源只能被多个读锁同时访问或者只能被一个写锁访问,而读和写不能同时操作这个共享资源。
//ReentrantReadWriteLock类源码
package java.util.concurrent.locks;
import java.util.concurrent.TimeUnit;
import java.util.Collection;
/*
* @since 1.5
*/
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {
}
代码演示
未加锁的并发情况代码分析
package com.fly.ecoco10;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
//资源类
public class MyCache {
private volatile Map<String, Object> map = new HashMap<>();
/**
* 定义写操作
* 需要满足:原子 + 独占
*/
public void put(String key, Object value) {
System.out.println(Thread.currentThread().getName() + "\t 正在写入:" + key);
try {
// 模拟网络拥堵,延迟0.3秒
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t 写入完成"+ key);
}
public void get(String key) {
System.out.println(Thread.currentThread().getName() + "\t 正在读取:"+ key);
try {
// 模拟网络拥堵,延迟0.3秒
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object value = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + value);
}
}
//使用使用
@Test
void readAndWriteLock() {
MyCache myCache = new MyCache();
//线程操作资源类,3个线程写
for (int i = 0; i < 3; i++) {
// lambda表达式内部必须是final
final int tempInt = i;
new Thread(() -> {
myCache.put(tempInt + "", tempInt + "");
}, String.valueOf(i)).start();
}
//线程操作资源类, 3个线程读
for (int i = 0; i < 3; i++) {
// lambda表达式内部必须是final
final int tempInt = i;
new Thread(() -> {
myCache.get(tempInt + "");
}, String.valueOf(i)).start();
}
while (Thread.activeCount() > 2) {
}
}
//结果分析:由未加任何锁,所以在并发情况下会出问题
0 正在写入:0
1 正在写入:1
2 正在写入:2
0 正在读取:0
1 正在读取:1
2 正在读取:2
2 写入完成2
0 写入完成0
1 写入完成1
1 读取完成:1
0 读取完成:0
2 读取完成:2
解决:通过加ReentrantLock和Synchronized可以达到效果,但是这两个属于独占锁,即多个读取的线程也会导致阻塞等待,所以不推荐使用,我们可以使用读写锁ReentrantReadWriteLock。
尝试先仅加读锁。效果:写此时控制主了:是按照顺序的,但在写的时候,读锁乱了顺序。 ```java //资源类 public class MyCache { private volatile Map
map = new HashMap<>(); private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); /**
- 定义写操作
需要满足:原子 + 独占 */ public void put(String key, Object value) { readWriteLock.writeLock().lock(); System.out.println(Thread.currentThread().getName() + “\t 正在写入:” + key); try {
// 模拟网络拥堵,延迟0.3秒
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} map.put(key, value); System.out.println(Thread.currentThread().getName() + “\t 写入完成” + key); readWriteLock.writeLock().unlock(); }
public void get(String key) { System.out.println(Thread.currentThread().getName() + “\t 正在读取:” + key); try {
// 模拟网络拥堵,延迟0.3秒
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} Object value = map.get(key); System.out.println(Thread.currentThread().getName() + “\t 读取完成:” + value); } }
//最终效果 0 正在写入:0 0 正在读取:0 1 正在读取:1 2 正在读取:2 0 写入完成0 1 正在写入:1 0 读取完成:0 1 读取完成:null 2 读取完成:null 1 写入完成1 2 正在写入:2 2 写入完成2
- 同时加上读锁和写锁:写的时候,读和其他写会阻塞;读和读可以同步进行。
```java
//资源类
public class MyCache {
private volatile Map<String, Object> map = new HashMap<>();
private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
/**
* 定义写操作
* 需要满足:原子 + 独占
*/
public void put(String key, Object value) {
readWriteLock.writeLock().lock();
System.out.println(Thread.currentThread().getName() + "\t 正在写入:" + key);
try {
// 模拟网络拥堵,延迟0.3秒
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t 写入完成" + key);
readWriteLock.writeLock().unlock();
}
public void get(String key) {
readWriteLock.readLock().lock();
System.out.println(Thread.currentThread().getName() + "\t 正在读取:" + key);
try {
// 模拟网络拥堵,延迟0.3秒
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object value = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + value);
readWriteLock.readLock().unlock();
}
}
//效果
1 正在写入:1
1 写入完成1
0 正在写入:0
0 写入完成0
2 正在写入:2
2 写入完成2
0 正在读取:0
1 正在读取:1
2 正在读取:2
0 读取完成:0
2 读取完成:2
1 读取完成:1
完整测试:测试先读-写,写-读的情况 ```java public class MyCache { private volatile Map
map = new HashMap<>(); private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); /**
- 定义写操作
需要满足:原子 + 独占 */ public void put(String key, Object value) { System.out.println(“写锁正在外面等待” + System.currentTimeMillis()); readWriteLock.writeLock().lock(); System.out.println(“正在写入:” + key + “当前时间:” + System.currentTimeMillis()); try {
// 模拟网络拥堵,延迟0.3秒
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} map.put(key, value); System.out.println(“写入完成” + key); readWriteLock.writeLock().unlock(); }
public void get(String key) { System.out.println(“读锁正在外面等待” + System.currentTimeMillis()); readWriteLock.readLock().lock(); System.out.println(“正在读取:” + key); try {
// 模拟网络拥堵,延迟0.3秒
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
} Object value = map.get(key); System.out.println(“读取完成:” + value + “当前时间:” + System.currentTimeMillis()); readWriteLock.readLock().unlock(); } }
@Test void readAndWriteLock() throws InterruptedException { MyCache myCache = new MyCache();
//先使用线程读
for (int i = 0; i < 1; i++) {
//lambda表达式内部必须是final
final int tempInt = i;
new Thread(() -> {
myCache.get(tempInt + "");
}, String.valueOf(i)).start();
}
TimeUnit.MICROSECONDS.sleep(300);
//再使用线程写
for (int i = 0; i < 1; i++) {
// lambda表达式内部必须是final
final int tempInt = i;
new Thread(() -> {
myCache.put(tempInt + "", tempInt + "");
}, String.valueOf(i)).start();
}
while (Thread.activeCount() > 2) {
}
}
//读-写的效果:显而易见,读锁没释放前,写锁拿不到锁。 读锁正在外面等待1644306109104 正在读取:0 写锁正在外面等待1644306109105 读取完成:null当前时间:1644306112104 正在写入:0当前时间:1644306112104 写入完成0
//写-读的效果:必须先写完然后才能读。 写锁正在外面等待1644306484879 正在写入:0当前时间:1644306484879 读锁正在外面等待1644306484880 写入完成0 正在读取:0 读取完成:0当前时间:1644306488179
> **锁的降级**
概念:锁的严苛程度变强叫做升级,反之叫做降级。
- 多个线程间:写锁和读锁是互斥的,即一个线程获得了读锁,另一个线程就不能获取写锁。一个线程获得了写锁,另一个线程就不能获取读锁或写锁。
- 同一个线程内
- 某个线程获取到写锁后同时又可以获取到读锁,这个理论是锁降级的前提:某个线程获得了写锁后内部再去拥有一把读锁,随后再释放写锁,此时锁就降级为了读锁,此为锁的降级。
锁降级常常用于让当前线程感知到数据的变化,目的是为了保证数据可见性。
- 但是获取到了读锁后就不能继续获取写锁了,这是因为读写锁要保持写操作的可见性(如果允许读锁在被获取的情况下对写锁的获取,那么正在运行的其他读线程无法感知到当前写线程的操作)。线程获取读锁后是不能再去申请升级为写锁的(必须先放弃读锁),即锁只支持降级,不支持升级
案例演示
package generices;
import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantReadWriteLock;
class Test { public static void main(String[] args) throws InterruptedException { //2个写线程 for (int i = 0; i < 1; i++) { new Thread(() -> { try { RenReadAndWrite.write(); } catch (InterruptedException e) { e.printStackTrace(); } }, “写线程” + i).start(); } TimeUnit.MILLISECONDS.sleep(1); //5个读线程 for (int i = 0; i < 5; i++) { new Thread(() -> RenReadAndWrite.read(), “读线程” + i).start(); } } } class RenReadAndWrite { private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private static final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock(); private static final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
public static void write() throws InterruptedException {
//线程先拥有了写锁
writeLock.lock();
//获得了写锁后线程可以再去申请了一个读锁,此时该线程是同时拥有了写锁和读锁。
//因为写锁具有唯一互斥性,所以其他线程都不能再获得写锁和读锁
readLock.lock();
System.out.println(Thread.currentThread().getName() + "获得写+锁 :" + System.currentTimeMillis());
TimeUnit.MILLISECONDS.sleep(100);//线程休眠
writeLock.unlock();//释放写锁,此时读锁就可以在多线程间共享了
System.out.println(Thread.currentThread().getName() + "休眠完成,释放写锁:" + System.currentTimeMillis());
TimeUnit.MILLISECONDS.sleep(100);//线程休眠
readLock.unlock();//释放读锁
}
public static void read() {
try {
readLock.lock();
System.out.println(Thread.currentThread().getName() + "正在读" + System.currentTimeMillis());
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "读完成" + System.currentTimeMillis());
readLock.unlock();
}
}
}
//演示结果——-详见下方截图分析 写线程0获得写+锁 :1645510585143 写线程0休眠完成,释放写锁:1645510585252 读线程0正在读1645510585252 读线程1正在读1645510585252 读线程2正在读1645510585252 读线程3正在读1645510585252 读线程4正在读1645510585252 读线程3读完成1645510585362 读线程2读完成1645510585362 读线程0读完成1645510585362 读线程1读完成1645510585362 读线程4读完成1645510585362
![image.png](https://cdn.nlark.com/yuque/0/2022/png/696107/1645511840788-1f776595-52e7-4ed4-85c5-4f3dbcff9932.png#clientId=ua341086c-8a7c-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=771&id=ub38734b9&margin=%5Bobject%20Object%5D&name=image.png&originHeight=848&originWidth=1646&originalType=binary&ratio=1&rotation=0&showTitle=false&size=145982&status=done&style=none&taskId=ue6282a87-3906-4322-bc2b-50734d4a5c5&title=&width=1496.3636039308287)<br />实际应用:缓存时使用<br />下面的示例代码摘自ReentrantWriteReadLock源码中:ReentrantWriteReadLock支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁,不支持锁升级。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/696107/1645511909817-e0589317-9ca4-4588-be61-1a6c3d85e149.png#clientId=ua341086c-8a7c-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=599&id=uae656c88&margin=%5Bobject%20Object%5D&name=image.png&originHeight=630&originWidth=995&originalType=binary&ratio=1&rotation=0&showTitle=false&size=133317&status=done&style=none&taskId=u51e2a6b6-325d-4160-be33-7bddd28a1ea&title=&width=945.54541015625)<br />解读:
1. 代码中声明了一个volatile类型的cacheValid变量,保证其可见性。
1. 首先获取读锁,如果cache不可用,则释放读锁,获取写锁,在更改数据之前,再检查一次cacheValid的值,然后修改数据,将cacheValid置为true,然后在释放写锁前获取读锁;此时,cache中数据可用,处理cache中数据,最后释放读锁。这个过程就是一个完整的锁降级的过程,目的是保证数据可见性。
1. 如果违背锁降级的步骤:如果当前的线程C在修改完cache中的数据后,没有获取读锁而是直接释放了写锁,那么假设此时另一个线程D获取了写锁并修改了数据,那么C线程无法感知到数据已被修改,则数据出现错误。
1. 如果遵循锁降级的步骤 :线程C在释放写锁之前获取读锁,那么线程D在获取写锁时将被阻塞,直到线程C完成数据处理过程,释放读锁。这样可以保证返回的数据是这次更新的数据,该机制是专门为了缓存设计的。
<a name="UNeOT"></a>
## 邮戳锁StampedLock
<a name="fuWNW"></a>
## synchronized与Lock的对比
传统的在做线程通信(阻塞与通知)的方式有两种,具体可详见`线程的通信同步`一节
- synchronized+ wait+ notify
- lock+ await+ signal
synchronized 和 lock 有什么区别?
| | **synchronized关键字** | **Lock接口** |
| --- | --- | --- |
| **锁的层面** | synchronized属于JVM层面,属于java的关键字。<br />synchronized底层是monitorenter(底层再通过monitor对象来完成,lockObj的wait/notify等方法也依赖于monitor对象,故只能在同步块或者方法中才能调用wait/notify等方法)。 | Lock是具体类(java.util.concurrent.locks.Lock)是api层面的锁。 |
| **锁的分类** | 不需要用户去手动释放锁,当synchronized代码执行后,系统会自动让线程释放对锁的占用。<br />synchronized是隐式锁,出了作用域自动释放。<br />synchronized有代码块锁和方法锁。 | 需要用户去手动释放锁,若没有主动释放锁,就有可能出现死锁的现象,需要lock() 和 unlock() 配置try catch语句来完成。<br />Lock是显式锁(手动开启和关闭锁,要记得关闭锁)。<br />Lock只有代码块锁。 |
| **等待是否中断** | 不可中断,除非抛出异常或者正常运行完成 | ReentrantLock:可中断,可以设置超时方法。<br />设置超时的方法:trylock(long timeout, TimeUnit unit)。该方法内部:lockInterrupible() 放代码块中,调用ThreadObj.interrupt() 方法可以中断 |
| **加锁是否公平** | 非公平锁 | ReentrantLock:默认非公平锁,构造函数可以传递boolean值,true为公平锁,false为非公平锁 |
| **锁绑定多个条件Condition** | 没有,要么随机,要么全部唤醒 | ReentrantLock:用来实现分组唤醒需要唤醒的线程,可以精确唤醒,而不是像synchronized那样,要么随机,要么全部唤醒 |
| | 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)。<br />优先使用顺序:Lock > 同步代码块(已经进入了方法体,分配了相应资源)> 同步方法(在方法体之外,锁的资源相对更多) | |
<a name="FK3wr"></a>
# 2 同步互斥封装:同步器synchronizer
<a name="NaxKg"></a>
## 概览
`java.util.concurrent`包包含了几个能帮助人们管理相互合作的线程集的类。 这些机制具有为线程之间的共用集结点模式提供的“预置功能”( canned functionality )。如果有一个相互合作的线程集满足这些行为模式之一, 那么应该直接重用合适的库类而不要试图提供手工的锁与条件的集合。总而概之:这些同步器是Java封装好的一系列解决方案,使用这些解决方案可以解决一些现成的问题,而不用自己再去组合编码。
| **类 ** | **它能做什么** | **说 明** |
| --- | --- | --- |
| Semaphore | 允许线程集等待直到被允许继续运行为止 | 限制访问资源的线程总数 。 如果许可数是 1,常常阻塞线程直到另一个线程给出许可为止 |
| CountDownLatch | 允许线程集阻塞等待直到计数器减为0 | 当一个或多个线程需要等待直到指定数目的事件发生 |
| CyclicBarrier | 允许线程集等待直至其中预定数目的线程到达一个公共障栅 (barrier ), 然后可以选择执行一个处理障栅的动作。 | 当大量的线程需要在它们的结果可用之前完成时 |
| Phaser | 类似于循环障栅 , 不过有一个可变的计数 | Java SE 7 中引人 |
| Exchanger | 允许两个线程在要交换的对象准备好时交换对象 | 当两个线程工作在同一数据结构的两个实例上的时候 , 一个向实例添加数据而另一个从实例清除数据 |
| SynchronousQueue | 允许一个线程把对象交给另一个线程 | 在没有显式同步的情况下 , 当两个线程准备好将一个对象从一个线程传递到另一个时 |
| **如上的类均在package java.util.concurrent包下** | | |
<a name="NfKWZ"></a>
## Semaphore信号量
> **概念**
Semaphore类存在于`java.util.concurrent`包下,Semaphore信号量对象维持了一组许可证(permit),其有两个核心方法:`acquire()`抢占信号量,即会消耗一个信号量;`release()`释放信号量,即会增加一个信号量。同步参考文档:[Redisson-分布式锁的信号量机制](https://www.yuque.com/zhuyufei-x9kmd/zx2opk/zxbwtl#XfQ8E) [Semaphore:信号量](https://gitee.com/moxi159753/LearningNotes/tree/master/%E6%A0%A1%E6%8B%9B%E9%9D%A2%E8%AF%95/JUC/7_CountDownLatch_CyclicBarrier_Semaphore%E4%BD%BF%E7%94%A8/Semaphore)<br />使用着重注意点:
- `release()`不是必须由获取它的线程释放。 事实上 , 任何线程都可以释放任意数目的许可,这可能会增加permit以至于超出初始数目,即如果一直调用release()则信号量会一直往上增加。
- 如果有必要,每个`acquire()`都会阻塞,直到许可证可用后才能使用它。 每个`release()`添加许可证,潜在地释放阻塞获取方。
作用:一个是用于共享资源的互斥使用;另一个用于并发线程数的控制。<br />常见的应用场景:秒杀商品业务,初始化上架一个商品总量`Semaphore semaphore = new Semaphore(1000, false);`,每个请求过来调用调用`semaphore.acquire()`或`semaphore.tryAcquire()`消耗一个,直到最后抢占为0,从而达到并发效果。
> **模拟停车场的应用案例**
```java
public class SemaphoreDemo {
public static void main(String[] args) {
//初始化一个信号量为3,默认是false即非公平锁
//模拟3个停车位,也可用于秒杀商品总量
Semaphore semaphore = new Semaphore(3, false);
//模拟6部车
for (int i = 0; i < 6; i++) {
new Thread(() -> {
try {
//代表一辆车,已经占用了该车位
semaphore.acquire(); //抢占
System.out.println(Thread.currentThread().getName() + "\t 抢到车位");
// 每个车停3秒
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t 离开车位");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放停车位:信号量加一,如果这里用了循环,则信号量会一直加,而不限制一定就是初始值的3
semaphore.release();
}
}, String.valueOf(i)).start();
}
}
}
//结果:看运行结果能够发现,0 2 1车辆首先抢占到了停车位,然后等待3秒后,离开,然后后面3 4 5又抢到了车位
0 抢到车位
2 抢到车位
1 抢到车位
2 离开车位
1 离开车位
3 抢到车位
0 离开车位
4 抢到车位
5 抢到车位
4 离开车位
3 离开车位
5 离开车位
需要特别注意的是semaphore.release();
方法如果不加控制,信号量会一直往上增加。
public class SemaphoreDemo {
private static volatile Semaphore semaphore = new Semaphore(3);
public static void main(String[] args) throws InterruptedException {
new Thread() {
@Override
public void run() {
super.run();
//这里使用了while循环,则信号量会一直往上加。直到这个线程本身执行时间片到了。
while (true) {
semaphore.release();
}
}
}.start();
new Thread() {
@Override
public void run() {
super.run();
try {
//这里循环调用acquire的截止条件为:信号量为0,否则会一直减少。当减少为0的时候。
while (true) {
semaphore.acquire();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}.start();
}
}
//效果
车开走了
车开走了
车开走了
车开走了
车开走了
车开走了
车开走了
车开走了
车开走了
车开走了
//后面是N个车开走了,代表此时信号量逐步往上增加了
车开进来了
车开进来了
车开进来了
车开进来了
车开进来了
车开进来了
//后面是N个车开进来了,代表release()所在线程添加了n个信号量后,该线程的时间片到了,此时轮到消耗信号量线程执行了。
CountDownLatch闭锁
概念:让一些线程阻塞直到另一些线程完成一系列操作才被唤醒。CountDownLatch倒计时门栓是一次性的。 一旦计数为0,就不能再重用了。
使用方法:CountDownLatch主要有两个方法,当一个或多个线程调用await方法时,调用线程就会被阻塞。其它线程调用CountDown()方法会将计数器减1(调用CountDown()方法的线程不会被阻塞),当计数器的值变成零时,因调用await方法被阻塞的线程会被唤醒,继续执行。
业务场景:假设一个自习室里有7个人,其中有一个是班长,班长的主要职责就是在其它6个同学走了后,关灯,锁教室门,然后走人,因此班长是需要最后一个走的,那么有什么方法能够控制班长这个线程是最后一个执行,而其它线程是随机执行的
完整代码
package com.fly.ecoco10;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class CountDownLatchDemo {
private static CountDownLatch countDownLatch = new CountDownLatch(6);
public static void main(String[] args) {
//子线程调用countDownLatch.await();发现值大于0,则会堵塞。
//直到countdownlatch的值减少为0时,自动唤醒
new Thread() {
@Override
public void run() {
super.run();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("进入到子线程了");
}
}.start();
//=======更新子线程======//
for (int i = 0; i < 10; i++) {
new Thread() {
@Override
public void run() {
super.run();
countDownLatch.countDown();
System.out.println("减少了一个,剩余" + countDownLatch.getCount());
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}.start();
}
//=======主线程======//
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("进入到main方法了");
}
}
//效果
减少了一个,剩余5
减少了一个,剩余3
减少了一个,剩余4
减少了一个,剩余2
减少了一个,剩余1
减少了一个,剩余0
进入到子线程了
减少了一个,剩余0
减少了一个,剩余0
减少了一个,剩余0
进入到main方法了
减少了一个,剩余0
CyclicBarrier障栅
概念
CyclicBarrier的字面意思就是可循环(cyclic)使用的屏障(Barrier)。CyclicBarrier的需求是:让一组线程到达一个屏障(也可以叫同步点,即CyclicBarrier设置的int parties
值)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。障栅被称为是循环的 ( cyclic ) , 因为可以在所有等待线程被释放后被重用。 在这一点上,有别于CountDownLatch,CountDownLatch只能被使用一次。
如何使用
定义:CyclicBarrier barrier = new CydicBarrier (int parties);
每一个线程做一些工作, 完成后在障栅上调用 await方法阻塞自己:public void run(){ doWork(); barrier.await()};
需要注意的时候:如果任何一个在障栅上等待的线程离开了障栅 , 那么障栅就被破坏了 ( 线程可能离开是因为它调用 await 时设置了超时,或者因为它被中断了)。在这种情况下,所有其他线程的await方法抛出BrokenBarrierException异常。那些已经在等待的线程立即终止await的调用。
另外可以提供一个可选的障栅动作 ( barrier action ) , 当所有线程到达障栅的时候就会执行这一动作,如集齐七颗龙珠,召唤神龙,这个action即召唤神龙这个动作。
使用场景
大量线程运行在一次计算的不同部分。 当所有部分都准备好时 , 需要把结果组合在一起 。 当一个线程完成了它的那部分任务后 , 我们让它运行到障栅处 。 一旦所有的线程都到达了这个障栅,障栅就撤销 , 线程就可以继续运行。
如集齐七颗龙珠,召唤神龙,用CyclicBarrier来实现就是:7个人(线程)去收集龙珠,每个线程收到1个就调用cyclicBarrier.await()阻塞自己,直到7颗龙珠集齐了才继续做自己的事情。同时可以设置集齐七颗龙珠可以召唤神龙这个设定。
代码演示
public class CyclicBarrierDemo {
/*
定义一个循环屏障
参数1:parties,同行着,即屏障数。
参数2:当屏障数达到参数1规定的值时,需要调用执行的方法
*/
static CyclicBarrier cyclicBarrier = new CyclicBarrier(7, new MyRunnable());
public static void main(String[] args) {
for (int i = 1; i < 10; i++) {
new Thread(() -> {
try {
System.out.println("集齐了龙珠" + Thread.currentThread().getName());
//会使屏障数+1,即parties++。同时判断如果没有达到指定的屏障数据,则会阻塞该线程接下来的方法。
//等到cyclicBarrier达到指定的值后,才会继续执行。
cyclicBarrier.await();
System.out.println("after:集齐了龙珠" + Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}, String.valueOf(i)).start();
}
}
}
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("召唤神龙");
}
}
//效果:可以看到所有线程在栅珊集齐之前,都阻塞了。在集齐之后,做了两件事情:1、召唤神龙;2、各自线程继续运行。
//同时需要注意的:我们没有看到after:集齐了龙珠8和after:集齐了龙珠9,因为屏障是循环的cyclic的,所以这两个继续阻塞(可以看到程序此时并没有结束,处于阻塞状态)
集齐了龙珠1
集齐了龙珠2
集齐了龙珠3
集齐了龙珠5
集齐了龙珠4
集齐了龙珠6
集齐了龙珠7
召唤神龙
集齐了龙珠9
after:集齐了龙珠2
after:集齐了龙珠4
集齐了龙珠8
after:集齐了龙珠1
after:集齐了龙珠7
after:集齐了龙珠6
after:集齐了龙珠5
after:集齐了龙珠3
3 轻量级同步:volatile可见性
基本概述
参考:谈谈volatile volatile-update
说明
- 有时仅仅为了读写一个或两个实例域就使用同步锁,显得开销过大了。假设对共享变量除了赋值之外并不完成其他操作,那么可以将这些共享变量声明为volatile。
- volatile是Java虚拟机提供的轻量级的免锁的同步机制(相对于synchronized和Lock的加锁机制)。
- volatile的变量写操作happen-before,后面任何对此volatile变量的读操作。
volatile的内存语义
- volatile关键字为变量的同步访问提供了一种免锁机制 。 如果声明一个域为 volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的 。
- 当写 一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
- 所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取
volatile的应用场景
- volatile可以适用于,某个标识flag,一旦被修改了就需要被其他线程立即可见的情况。也可以修饰作为触发器的变量,一旦变量被任何一个线程修改了,就去触发执行某个操作。
- 【阿里巴巴编程参考】volatile可解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。
- 如果是 count++操作,使用如下类实现:
AtomicInteger count = new AtomicInteger(); count.addAndGet(1);
- 如果是JDK8,推荐使用 LongAdder 对象,比 AtomicLong 性能更好(减少乐观锁的重试次数)。
- 如果是 count++操作,使用如下类实现:
volatile的三大特性
- 保证可见性
- 不保证原子性
- 有序性(禁止指令重排)
应用:单例模式
一段非绝对线程安全的代码(在99.9999%情况下使用是正常的)
/**
* SingletonDemo(单例模式)
*/
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo () {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if(instance == null) {
instance = new SingletonDemo();
}
return instance;
}
//双端锁检测机制
public static SingletonDemo getInstance() {
if(instance == null) {
//同步代码段的时候,进行检测
synchronized (SingletonDemo.class) {
if(instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
}
为什么说上面的这段代码是在99.9999%下是正常的呢?原因是上面的代码不一定是线程安全的,原因是有指令重排的存在,虽然出现这个线程不安全的概率及低:
我们看这段代码instance = new SingletonDemo();
实际的分解过程:
memory = allocate(); // 1、分配对象内存空间
instance(memory); // 2、初始化对象
instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null
我们能够发现,步骤2和步骤3之间不存在数据依赖关系,而且无论重排前还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
这样就会造成什么问题呢?就是当指令重排后的顺序是1、3、2时且当线程A在同步代码块里面执行到步骤3还没执行步骤2,线程就发生了切换,线程B访问了if(instance == null)
得到instance不为null并且返回了instance,但其实,此时步骤2初始化对象的工作还没完成,线程B得到的是一个空对象(或者可以说是不完整的对象),虽然这时候确实instance!=null
(因为已经执行了内存空间了,当然不为null,只是这个空间里面还没有初始化完成,譬如里面的对象头设置等还没完成,所以对象使用是有问题的)。
最终线程安全代码
仅需要在实例对象上加上volatile修饰符。
/**
* SingletonDemo(单例模式)
*/
public class SingletonDemo {
private static volatile SingletonDemo instance = null;
private SingletonDemo () {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if(instance == null) {
//a 双重检查加锁多线程情况下会出现某个线程虽然这里已经为空,但是另外一个线程已经执行到d处
synchronized (SingletonDemo.class) //b
{
//c不加volitale关键字的话有可能会出现尚未完全初始化就获取到的情况。原因是内存模型允许无序写入
if(instance == null) {
//d 此时才开始初始化
instance = new SingletonDemo();
}
}
}
return instance;
}
//测试
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
}
}
内存屏障:可见性和有序性的实现机制
概览及作用
内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作)以避免代码重排序。内存屏障其实就是一种JVM指令。
它的作用有两个:
- 保证volatile变量的有序性:实现特定操作的顺序。
- 保证volatile变量的可见性:内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果。
- 一句话:对一个volatile变量的写,happens-before于任意后续对这个volatile域的读,也叫写后读。
如何实现的:四大屏障指令(LoadLoad、StoreStore、LoadStore、StoreLoad)
当我们的Java程序的变量被volatile修饰之后,会添加一个ACC_VOLATI LE,JVM会把字节码生成为机器码的时候,发现操作是volatile变量的话,就会根据JVM要求,在相应的位置去根据情况插入这4条内存屏障指令:StoreStore、StoreLoad 、LoadLoad、LoadStore。
扩展说明:JMM的happens-before先行发生原则,类似接口规范,volatile关键字是如何落地实现这个约束的:也是靠的是StoreStore、StoreLoad 、LoadLoad、LoadStore四条指令。
哪些情况插入哪些屏障指令?
总结
- 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
- 第二个Volatile写当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后。
- 当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。
既然一修改就可见,为什么还实现不了原子性?
要use(使用)一个变量的时候必需load(载入),要载入的时候必需从主内存read(读取)这样就解决了读的可见性。写操作是把assign和store做了关联:assign(赋值)后必需store(存储),store(存储)后write(写入)。也就是做到了给一个变量赋值的时候一串关联指令直接把变量值写到主内存。就这样通过用的时候直接从主内存取,在赋值到直接写回主内存做到了内存可见性。
为什么做不到原子性:read-load-use 和 assign-store-write 成为了两个不可分割的原子操作,但是在use和assign之间依然有极小的一段真空期,即在CPU写回内存之间没有做到强隔离性,如果存在多次写的话就有可能变量会被其他线程读取,导致写丢失一次。
【阿里巴巴编程参考】volatile可解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。
代码演示
//模拟一个单线程,什么顺序读?什么顺序写?
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write(){
i = 2;
flag = true;
}
public void read(){
if(flag){
System.out.println("---i = " + i);
}
}
}
可见性
JMM一节的代码做如下微调:Dog类的count加上volatile关键字。关于JMM的知识详见本文档最末节。
volatile怎们实现的可见性:通过内存屏障。
public class Voliate {
public static void main(String[] args) throws InterruptedException {
Dog dog = new Dog();
Thread thread = new Thread() {
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + "\t come in"+ "当前时间:" + System.currentTimeMillis());
//线程暂停1s
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
dog.changeTo60();
System.out.println(Thread.currentThread().getName() + "\t update number value:" + dog.count + "当前时间:" + System.currentTimeMillis());
}
};
thread.start();
while (dog.count==0) {
//main线程就一直在这里等待循环,直到number的值不等于零
}
//按道理这个值是不可能打印出来的,因为主线程运行的时候,number的值为0,所以一直在循环
//如果能输出这句话,说明子线程线程在睡眠3秒后,更新到了number的值,重新写入到主内存,并被main线程感知到了
System.out.println(Thread.currentThread().getName() + "\t mission is over" + "当前时间:" + System.currentTimeMillis());
}
}
class Dog {
//volatile 修饰的关键字,是为了增加主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知
public volatile int count = 0;
public void changeTo60() {
count = 60;
}
}
//输出结果:更新完成后通过volatile可以立即更新到其他线程的拷贝
Thread-0 come in当前时间:1643257581948
Thread-0 update number value:60当前时间:1643257582948
main mission is over当前时间:1643257582948
参考:volatile机制 同步原理: 1、如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。 2、为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。 volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。
非原子性
有原子性并发问题的代码
public class VoliateTest {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
Thread thread = new Thread() {
@Override
public void run() {
for (int j = 0; j < 200; j++) {
integer.incrementAndGet();
}
}
};
thread.start();
}
//只有2个线程的时候退出循环:一个main,一个gc
while (Thread.activeCount() > 2) {
//降低当前线程的优先级
Thread.yield();
//该方法写在哪里就会阻塞对应的线程,直到调用线程执行完了
//子线程.join()
}
System.out.println(integer.get());
}
}
//输出5次,都没有到达目标的20000
19887
18778
19221
19117
17458
非原子性问你题分析:关于count++的字节码分析
- getstatic:它含有一个操作数,为指向常量池的Fieldref索引,它的作用就是获取Fieldref指定的对象或者值,并将其压入操作数栈。
- iconst_1:将常数1压入操作数栈
- iadd:将操作数栈的栈顶2个元素出栈相加并将结果入栈
- putstatic:更新常量池里面的数据
- return:返回值
假设我们没有加同步监视器:那么第一步就可能存在问题,假设有三个线程同时通过getfield命令,拿到主存中的count值,然后三个线程,各自在自己的工作内存中进行加1操作,但他们并发进行 iadd 命令的时候,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,volatile的可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是因为太快了,其它两个线程,陆续执行 iadd命令,进行写入操作,这就造成了其他线程没有接受到主内存n的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于20000。
改造:使用synchronized同步监视器(缺点是:每次都上锁,开销较大)
public class VoliateTest {
private static int count = 0;
private static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
Thread thread = new Thread() {
@Override
public void run() {
synchronized (object) {
for (int j = 0; j < 200; j++) {
count++;
}
}
}
};
thread.start();
}
//只有2个线程的时候退出循环:一个main,一个gc
while (Thread.activeCount() > 2) {
//降低当前线程的优先级
Thread.yield();
//该方法写在哪里就会阻塞对应的线程,直到调用线程执行完了
//子线程.join()
}
System.out.println(count);
}
}
//输出5次,每次都是20000
20000
使用原子类AtomicInteger(推荐使用)
public class VoliateTest {
private static AtomicInteger integer = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
Thread thread = new Thread() {
@Override
public void run() {
for (int j = 0; j < 200; j++) {
integer.incrementAndGet();
}
}
};
thread.start();
}
//只有2个线程的时候退出循环:一个main,一个gc
while (Thread.activeCount() > 2) {
//降低当前线程的优先级
Thread.yield();
//该方法写在哪里就会阻塞对应的线程,直到调用线程执行完了
//子线程.join()
}
System.out.println(integer.get());
}
}
有序性(禁止指令重排)
参考:指令重排gitee
概念说明
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,一般分为以下三种:源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令
- 单线程环境里面能确保最终执行结果和代码顺序的结果一致。
- 处理器在进行重排序时,必须要考虑指令之间的数据依赖性,多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
指令重排案例1
public void mySort() {
int x = 11;
int y = 12;
x = x + 5;
y = x * x;
}
按照正常单线程环境,执行顺序是 1 2 3 4
但是在多线程环境下,可能出现以下的顺序:2 1 3 4或1 3 2 4。
但是指令重排也是有限制的,即不会出现下面的顺序4 3 2 1。因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性,而步骤 4:需要依赖于y的申明,以及x的申明,故因为存在数据依赖,无法首先执行。
指令重排案例2
详见案例:指令重排 - example 2
volatile怎们实现的有序性:通过内存屏障
4 轻量级互斥同步:atomic原子性
应用场景:假设对共享变量除了赋值之外并不完成其他操作 , 那么可以将这些共享变量声明为volatile。但是由于volatile并不具有原子性,则会有并发问题。
java.util.concurrent.atomic包中则有很多类使用了很高效的机器级指令 (使用CAS而不是使用锁 ) 来保证其他操作的原子性 。具体原理可详见末尾的CAS一节。
部分原子类存在的ABA问题
参考文档
问题描述
假设现在有两个线程,分别是T1和T2,然后T1执行某个操作的时间为10秒,T2执行某个时间的操作是2秒,最开始AB两个线程,分别从主内存中获取A值,但是因为B的执行速度更快,他先把A的值改成B,然后又修改回了A,此时线程T2执行完毕。T1线程在10秒后,判断内存中的值为A,并且和自己预期的值一样,它就认为没有人更改了主内存中的值,就快乐的将该值修改成C,但是实际上主存中的值在这中间已经经历了 A->B->A的变换。
故ABA问题就是:线程1在获取主内存值的时候,CAS判断的预期值和更新值是一样的,故可以 更新。但其实这个主存的值已经被其他线程修改了N次了,即线程1只判断中间和结尾值,控制不了中间值的变化。
存在ABA问题的原子类:如AtomicReference
原子引用其实和原子包装类是差不多的概念,就是将一个java类,用原子引用类进行包装起来,那么这个类就具备了原子性。
public class Index {
static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);
public static void main(String[] args) {
//测试原子引用//
User z3 = new User("z3", 22);
User l4 = new User("l4", 25);
// 创建原子引用包装类
AtomicReference<User> atomicReference1 = new AtomicReference<>();
//现在主物理内存的共享变量,为z3
atomicReference1.set(z3);
//比较并交换,如果现在主物理内存的值为z3,那么交换成l4
System.out.println(atomicReference1.compareAndSet(z3, l4) + "\t " + atomicReference.get().toString());
//比较并交换,现在主物理内存的值是l4了,但是预期为z3,因此交换失败
System.out.println(atomicReference1.compareAndSet(z3, l4) + "\t " + atomicReference.get().toString());
//存在ABA问题的原子类
new Thread(() -> {
//把100 改成 101 然后在改成100,也就是ABA
atomicReference.compareAndSet(100, 101);
atomicReference.compareAndSet(101, 100);
}, "t1").start();
new Thread(() -> {
try {
// 睡眠一秒,保证t1线程,完成了ABA操作
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
//把100 改成 101 然后在改成100,也就是ABA
System.out.println(atomicReference.compareAndSet(100, 2019) + "\t" + atomicReference.get());
}, "t2").start();
}
}
@Data
class User {
String userName;
int age;
}
//输出结果
true 100
false 100
true 2019
使用带有时间戳功能的原子类来解决ABA问题:如AtomicStampedReference类
AtomicStampedReference在每次更新的时候,会比较期望值和当前值,以及期望版本号和当前版本号。如果都匹配中,才会进行更新。
public class ABADemo {
//传递两个值,一个是初始值,一个是初始版本号
static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 1);
public static void main(String[] args) {
System.out.println("============以下是ABA问题的解决==========");
new Thread(() -> {
//获取版本号
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp);
//暂停t3一秒钟
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
//传入4个值,期望值,更新值,期望版本号,更新版本号
atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(), atomicStampedReference.getStamp()+1);
System.out.println(Thread.currentThread().getName() + "\t 第二次版本号" + atomicStampedReference.getStamp());
atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp()+1);
System.out.println(Thread.currentThread().getName() + "\t 第三次版本号" + atomicStampedReference.getStamp());
}, "t3").start();
new Thread(() -> {
//获取版本号
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp);
//暂停t4线程3秒钟,t3线程也进行一次ABA问题
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean result = atomicStampedReference.compareAndSet(100, 2019, stamp, stamp+1);
System.out.println(Thread.currentThread().getName() + "\t 修改成功否:" + result + "\t 当前最新实际版本号:" + atomicStampedReference.getStamp());
System.out.println(Thread.currentThread().getName() + "\t 当前实际最新值" + atomicStampedReference.getReference());
}, "t4").start();
}
}
//输出结果:可以看到t4线程没有更新成功了
============以下是ABA问题的解决==========
t3 第一次版本号1
t4 第一次版本号1
t3 第二次版本号2
t3 第三次版本号3
t4 修改成功否:false 当前最新实际版本号:3
t4 当前实际最新值100
5 安全访问:final变量
上一节已经了解到 , 除非使用锁(syn和lock)或 volatile 修饰符, 否则无法从多个线程安全地读取一个域。
还有一种情况可以安全地访问一个共享域 , 即这个域声明为 final时。 考虑以下声明:final Map<String,Double> accounts = new HashMap<>();
其他线程会在构造函数完成构造之后才看到这个 accounts 变量 。
如果不使用 final,就不能保证其他线程看到的是accounts更新后的值,它们可能都只是看到 null , 而不是新构造的HashMap。当然, 对这个映射表的操作并不是线程安全的(如put方法)。如果多个线程在读写这个映射表,仍然需要进行同步。
10 同步辅助知识
JMM(Java内存模型)
概念说明
参考文档:
Java内存模型引入
Java内存模型详解
JMM有以下规定:所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题(除非是线程就是定义在方法里面,那么局部变量即可被线程内访问到,如下面的例子)。每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
JMM关于同步的规定
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值,到自己的工作内存
- 加锁和解锁是同一把锁
一个案例代码
public class Voliate {
public static void main(String[] args) throws InterruptedException {
Dog dog = new Dog();
Thread thread = new Thread() {
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + "\t come in");
//线程暂停1s
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
dog.changeTo60();
System.out.println(Thread.currentThread().getName() + "\t update number value:" + dog.count);
}
};
thread.start();
while (dog.count==0) {
//main线程就一直在这里等待循环,直到number的值不等于零
}
//按道理这个值是不可能打印出来的,因为主线程运行的时候,number的值为0,所以一直在循环
//如果能输出这句话,说明子线程线程在睡眠3秒后,更新到了number的值,重新写入到主内存,并被main线程感知到了
System.out.println(Thread.currentThread().getName() + "\t mission is over");
}
}
class Dog {
public int count = 0;
public void changeTo60() {
count = 60;
}
}
//输出结果
Thread-0 come in
Thread-0 update number value:60
//结果分析
代码一直卡在while循环语句,即主线程没有即时能接收到子线程里面的更新值。
缓存一致性
概念
缓存一致性即:当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSI、MESI等等。
MESI
当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
总线嗅探
那么是如何发现数据是否失效呢?
这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
总线风暴
总线嗅探技术有哪些缺点?
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此不要大量使用volatile关键字,至于什么时候使用volatile、什么时候用锁以及Syschonized都是需要根据实际场景的。
CAS(UnSafe)
概览
说明
- CAS的全称是Compare-And-Swap,它是CPU并发原语,它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。
- Java中的CAS操作的执行依赖于Unsafe类,Unsafe是CAS的核心类,Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存。注意Unsafe类的所有方法都是native修饰的,也就是说unsafe类中的方法都直接调用操作系统底层资源执行相应的任务。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令,这是一种完全依赖于硬件的功能,通过它实现了原子操作。
- CAS是一种系统原语,原语属于操作系统范畴,是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,不会造成所谓的数据不一致的问题,所以说CAS是线程安全的。
代码实现在:Unsafe类
代码方法技术:volatile+自旋(do while)+原语层面的比较和交换。CAS没有用到synchronized,而用CAS,这样提高了并发性,也能够实现一致性,是因为每个线程进来后,进入的do while循环,然后不断的获取内存中的值,判断是否为最新,然后再进行更新操作。
CAS的本质:是得到volatile修饰的内存值,通过自旋进行交换和设置,而这个交换和设置是操作系统原语层面的,是不会被分割的。
案例:假设线程A和线程B同时执行atomicInteger.getAndInt()
操作
- AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,根据JMM模型,线程A和线程B各自持有一份价值为3的副本,分别存储在各自的工作内存。
- 线程A通过getIntVolatile(var1 , var2) 拿到value值3,这是线程A被挂起(该线程失去CPU执行权)。
- 线程B也通过getIntVolatile(var1, var2)方法获取到value值也是3,此时刚好线程B没有被挂起,并执行了compareAndSwapInt方法,比较内存的值也是3,成功修改内存值为4,线程B打完收工,一切OK。
- 这时线程A恢复,执行CAS方法,比较发现自己手里的数字3和主内存中的数字4不一致,说明该值已经被其它线程抢先一步修改过了,那么A线程本次修改失败,只能够重新读取后在来一遍了,也就是在执行do while。
- 线程A重新获取value值,因为变量value被volatile修饰,所以其它线程对它的修改,线程A总能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功。
```java
//1.0 应用层:使用AtomicInteger,其内部使用到了CAS
public class CASDemo {
public static void main(String[] args) {
} } //如上代码的输出结果 true current data: 2019 false current data: 2019//创建一个原子类
AtomicInteger atomicInteger = new AtomicInteger(5);
//一个是期望值,一个是更新值,但期望值和原来的值相同时,才能够更改
System.out.println(atomicInteger.compareAndSet(5, 2019) + " current data: " + atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(5, 1024) + " current data: " + atomicInteger.get());
//2.0 源码AtomicInteger类:AtomicInteger.getAndIncrement()方法的源码 public class AtomicInteger{ public final int getAndIncrement() { /* this:当前调用对象
* valueOffset:表示该变量值在内存中的偏移地址,Unsafe就是根据该内存偏移地址获取到在内存中的实际数据的。
/
return unsafe.getAndAddInt(this, valueOffset, 1);
}
}
//3.0 源码Unsafe类:Unsafe.getAndAddInt方法的源码
public final class Unsafe {
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
//volatile自旋:因为是getIntVolatile,就代表每次都要先从主内存中拿到最新的值到自己线程内的本地内存,然后执行compareAndSwapInt()比较。====JMM内存模型
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
//实际的CAS代码
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);
具体应用
AtomicInteger等原子类就使用到了CAS思想。为什么Atomic修饰的包装类,能够保证原子性,依靠的就是底层的Unsafe类的CAS方法。
自旋锁也实现了CAS思想,具体详见自旋锁。
CAS的缺点
CAS不加锁,保证一次性,但是需要多次比较
- 循环时间长,开销大(因为执行的是do while,如果比较不成功一直在循环,最差的情况,就是某个线程一直取到的值和预期值都不一样,这样就会无限循环)
- 只能保证一个共享变量的原子操作
- 当对一个共享变量执行操作时,我们可以通过循环CAS的方式来保证原子操作
- 但是对于多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候只能用锁来保证原子性(因为锁可以锁住整个代码块和方法块,但是CAS就不可以了)。
- 部分原子类存在ABA问题
Happen-Before
一套总结出来的规则,程序员使用这些编码规则就能实现一个线程的写对其他线程的读可见。