文章目录
0、前言概述
一、并发编程中的三个问题
- 1.1 可见性
- 1.2 原子性
- 1.3 有序性(Ordering)
二、Java内存模型(JMM)
- 2.1 计算机结构
- 2.1.1 计算机结构简介
- 2.1.2 CPU
- 2.1.3 内存
- 2.1.4 缓存
- 2.1.5 小结
- 2.2 Java内存模型(JMM)
- 2.2.1 Java内存模型(JMM)的概念
- 2.2.2 Java内存模型的作用
- 2.2.3 CPU缓存、内存与Java内存模型的关系
- 2.2.4 小结
- 2.3 主内存与工作内存之间的交互
三、synchronized保证三大特性
- 3.1 synchronized与可见性
- 使用synchronized保证可见性
- synchronized保证可见性的原理
- 小结
- 3.2 synchronized与原子性
- 使用synchronized保证原子性
- synchronized保证原子性的原理
- 小结
- 3.3 synchronized与有序性
- 为什么要重排序
- as-if-serial语义
- 使用synchronized保证有序性
- synchronized保证有序性的原理
- 小结
四、synchronized的特性
- 4.1 可重入特性(针对同一个线程)
- 4.1.1 什么是可重入
- 4.1.2 可重入原理
- 4.1.3 可重入的好处
- 4.1.4 小结
- 4.2 不可中断特性
- 4.2.1 什么是不可中断
- 4.2.2 synchronized不可中断演示
- 4.2.3 ReentrantLock可中断演示
- 4.2.4 小结
五、synchronized底层原理
- 5.1 javap反汇编
- 5.1.1 monitorenter
- 5.1.2 monitorexit
- 5.1.3 同步方法
- 5.1.4 小结
- 5.1.5 面试题:synchronized与Lock的区别
- 5.2 深入JVM源码
- 5.2.1 JVM源码下载
- 5.2.2 monitor监视器锁
- 5.2.3 monitor竞争
- 5.2.4 monitor等待
- 5.2.5 monitor释放
- 5.2.6 monitor是重量级锁
六、JDK6 synchronized优化
- 6.1 CAS
- 6.1.1 CAS概述和作用
- 6.1.2 CAS和volatile实现无锁并发
- 6.1.3 CAS原理
- Unsafe类介绍
- Unsafe实现CAS
- 乐观锁和悲观锁
- 6.1.4 小结
- 6.2 synchronized锁升级过程
- 6.3 Java对象的布局
- 6.3.1 对象头
- Mark Word
- Klass pointer
- 6.3.2 实例数据
- 6.3.3 对齐填充
- 6.3.4 查看Java对象布局
- 6.3.5 小结
- 6.3.1 对象头
- 6.4 偏向锁
- 6.4.1 什么是偏向锁
- 6.4.2 偏向锁原理
- 6.4.3 偏向锁演示
- 6.4.3 偏向锁的撤销
- 6.4.4 偏向锁好处
- 6.4.5 小结
- 6.5 轻量级锁
- 6.5.1 什么是轻量级锁
- 6.5.2 轻量级锁原理
- 6.5.3 轻量级锁的撤销
- 6.5.4 轻量级锁好处
- 6.5.5 小结
- 6.6 自旋锁
- 6.6.1 自旋锁原理
- 6.6.2 适应性自旋锁
- 6.7 锁消除
- 6.8 锁粗化
- 6.9 平时写代码如何对synchronized优化
- 6.9.1 减少synchronized的范围
- 6.9.2 降低synchronized锁的粒度
- 6.9.3 读写分离
本文为 1~4小节,5、6节请查阅【并发编程】2 synchronized底层实现原理、Java内存模型JMM;可重入、不可中断、monitor、CAS、乐观锁和悲观锁;对象内存结构、Mark Word、synchronized锁升级
0、前言概述
- 控制台执行jar包:mvn clean install成功后,在terminal控制台执行 java -jar path/xxx.jar,
- 使用javap反汇编class文件:找到target下面的 类名.class 文件,cmd打开,控制台输入 javap -p -v xxx.class
在Java中,如果一个方法是native的(本地方法,由系统提供、并非Java语言实现),那Java就不负责具体实现它,而是交给底层的JVM使用C或者C++去实现。
HotSpot是jvm虚拟机的一种实现,现在jvm虚拟机大部分使用HotSpot实现
~
1.并发编程中存在3个问题:可见性、原子性、有序性
- 可见性:当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改后的最新值。
- 原子性:当一个线程对共享变量操作到一半时,另外的线程也有可能来操作共享变量,干扰了前一个线程的操作。
- 有序性:程序代码在执行过程中的先后顺序,由于Java在编译期以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序。
~
2.计算机结构
- 计算机由五大部分组成:运算器、控制器、存储器、输入设备、输出设备
- CPU:中央处理器,是计算机的控制和运算的核心,我们的程序最终都会变成指令让CPU去执行,处理程序中的数据。
- 内存:我们的程序都是在内存中运行的,内存会保存程序运行时的数据,供CPU处理。
- 缓存:Cache的出现是为了解决CPU直接访问内存效率低下问题的。CPU Cache分成了三个级别: L1, L2, L3。级别越小越接近CPU,速度也更快,同时也代表着容量越小。
~
3.Java内存模型(JMM)
和Java内存结构不同,Java内存模型是一套规范、是标准化的,屏蔽掉了底层不同计算机的区别(Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则)。它描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。细节如下。
- 主内存:主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
- 工作内存:每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量。
主内存与工作内存之间的数据交互过程:lock -> read -> load -> use -> assign -> store -> write -> unlock
~
4.synchronized可保证三大特性:可见性、原子性、有序性
- synchronized保证可见性的原理,执行synchronized时,对应lock原子操作会刷新工作内存中共享变量的值,获取主内存中共享变量的最新值。
- synchronized保证原子性的原理,synchronized保证只有一个线程拿到锁,能够进入同步代码块。
- synchronized保证有序性的原理,我们加synchronized后,依然会发生重排序,只不过我们有同步代码块,可以保证只有一个线程执行同步代码中的代码。保证有序性
补充volatile:volatile可以保证可见性、有序性,无法保证原子性(它对任意单个变量的读/写具有原子性,但是类似于i++这种复合操作不具有原子性)。【synchronized可以保证可见性、原子性、有序性,因为锁的互斥执行的特性可以确保对整个临界区代码执行具有原子性】
~
5.synchronized的特性:可重入、不可中断
- 可重入特性(针对同一个线程):synchronized是可重入锁,一个线程可以多次执行synchronized,重复获取同一把锁。内部锁对象中会有一个计数器记录线程获取几次锁,在执行完同步代码块时,计数器的数量会-1,直到计数器的数量为0,就释放这个锁。【可重入锁是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁;如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住】
- 不可中断特性:当一个线程获得锁后,另一个线程一直处于阻塞或等待状态,前一个线程不释放锁,后一个线程会一直阻塞或等待,不可被中断。 synchronized不可被中断,Lock的lock方法不可中断,Lock的tryLock方法是可中断的
补充ReentrantLock:与synchronized一样 支持可重入;可中断(有两种方式,可中断、不可中断);可设置超时时间;可设置为公平锁
~
6.synchronized底层原理
synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程,recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待
synchronized使用了monitorentor和monitorexit两个指令。每个锁对象都会关联一个monitor(监视器,它才是真正的锁对象),它内部有两个重要的成员变量owner会保存获得锁的线程,recursions会保存线程获得锁的次数,当执行到monitorexit时,recursions会-1,当计数器减到0时 这个线程就会释放锁。
同步方法会增加 ACC_SYNCHRONIZED 修饰,会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。
~
7.Monitor结构、竞争、等待、释放
monitor竞争、等待、释放 具体见正文解释
~
8.synchronized与Lock的区别
- synchronized是关键字,而Lock是一个接口【IDEA中查看Lock源码,ctrl+H 发现其有如下实现类:ReadLockView、ReentrantLock、WriteLock、NoLock、WriteLockView、ReadLock】。
- synchronized会自动释放锁,而Lock必须手动释放锁。
- synchronized是不可中断的,Lock可以中断也可以不中断。
- 通过Lock可以知道线程有没有拿到锁(
lock.tryLock()
方法返回boolean),而synchronized不能。 - synchronized能锁住方法和代码块,而Lock只能锁住代码块。
- Lock可以使用读锁提高多线程读效率,ReentrantReadWriteLock。
- synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。synchronized唤醒的时候并非按照先来后到的顺序唤醒,随机地唤醒一个线程
~
9.CAS
- CAS的作用? Compare And Swap,CAS可以将比较和交换转换为原子操作,这个原子操作直接由处理器保证。
- CAS的原理?CAS依赖3个值:当前内存值V,旧的预期值A,要修改的新值B。如果当前内存值V和旧的预期值A相等,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令
~
10**.乐观锁和悲观锁**
-
悲观锁从悲观的角度出发: 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞。因此synchronized我们也将其称之为悲观锁。JDK中的ReentrantLock也是一种悲观锁。性能较差!
-
乐观锁从乐观的角度出发: 总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,就算改了也没关系,再重试即可。所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去修改这个数据,如何没有人修改则更新,如果有人修改则重试。
CAS是乐观锁
~
11.对象的内存结构
对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,另外一部分是类型指针,及对象指向它的类元数据的指针。
~
12.Mark Word
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
在32位虚拟机下,Mark Word是32bit大小的,其存储结构如下:
现在绝大多数用的64位虚拟机,因此我们重点关注64位Mark Word。
~
13.synchronized锁升级:无锁–》偏向锁–》轻量级锁–》重量级锁
偏向锁:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中记录存储锁偏向的线程ID,以后该线程在进入同步块时先判断对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果存在就直接获取锁,虚拟机都可以不再进行任何同步操作,偏向锁的效率高
偏向锁的原理是什么?
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”、偏向锁设为1,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。
偏向锁的好处是什么?
偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。
轻量级锁:当其他线程尝试竞争偏向锁时,锁升级为轻量级锁。线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的MarkWord替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,标识其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级锁的原理是什么?
将对象的Mark Word复制到栈帧中的Lock Recod中。Mark Word更新为指向Lock Record的指针。
轻量级锁好处是什么?
在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。
自旋锁、自适应自旋锁
重量级锁:锁在原地循环等待的时候,是会消耗CPU资源的。所以自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么等待锁的线程会不断的循环反而会消耗CPU资源。默认情况下锁自旋的次数是10 次,可以使用-XX:PreBlockSpin参数来设置自旋锁等待的次数。10次后如果还没获取锁,则升级为重量级锁。
~
14.锁消除、锁粗化
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
什么是锁粗化?
JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。
~
15.平时写代码如何对synchronized优化
- 减少synchronized的范围:同步代码块中尽量短,减少同步代码块中代码的执行时间,减少锁的竞争。
- 降低synchronized锁的粒度:将一个锁拆分为多个锁提高并发度。尽量不要用
类名.class
作为锁,降低锁的粒度,提高并发性能。并发情况下,尽量不要使用Hashtable,推荐使用ConcurrentHashMap、LinkedBlockingQueue - 读写分离:读取时不加锁,写入和删除时加锁。譬如 ConcurrentHashMap,CopyOnWriteArrayList和ConyOnWriteSet
参考黑马程序员相关课程,https://www.bilibili.com/video/BV1aJ411V763/
一、并发编程中的三个问题
提前说明:由于System.out.println()函数中用到了synchronized关键字,此处我们只分析 无synchronized情况下的并发问题,故不用System.out.println()打印输出结果,选择LOG.info()。需要在pom文件中引入以下依赖
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.2</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.2</version>
</dependency>
println源码
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
1.1 可见性
概念:当一个线程对共享变量进行了修改,另外的线程立即得到修改后的最新值
演示:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
案例演示: 一个线程对共享变量的修改,另一个线程不能立即得到最新值
*/
public class Test01Visibility {
private final static Logger LOG = LoggerFactory.getLogger(Test01Visibility.class);
// 多个线程都会访问的数据,我们称为线程的共享数据
//1.创建一个共享变量
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
//2.创建一个线程不断读取共享变量
new Thread(() -> {
while(run) {
}
LOG.info("{} 读取run:{},线程结束", Thread.currentThread().getName(), run);
// System.out.println(Thread.currentThread().getName() + " 读取run:" + run + ",线程结束");
}, "Thread1").start();
Thread.sleep(1000);
//3.创建一个线程修改共享变量
new Thread(() -> {
run = false;
LOG.info( "{} 修改run值为:{}", Thread.currentThread().getName(), run);
// System.out.println(Thread.currentThread().getName() + " 修改run值为:" + run);
}, "Thread2").start();
}
}
由输出可知,虽然Thread2已经将run修改为false,但线程Thread1没有读取到run修改后的值,Thread1一直在运行。
小结:并发编程时,会出现可见性问题,当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改后的最新值。
1.2 原子性
概念:在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行。
演示:5个线程各执行1000次 i++;
/**
* 目标:演示原子性问题
* 1.定义一个共享变量number
* 2.对number进行1000的++操作
* 3.使用5个线程来进行
*/
public class Test02Atomicity {
//1.定义一个共享变量number
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
//2.对number进行1000的++操作
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
number++;
}
};
List<Thread> list = new ArrayList<>();
//3.使用5个线程来进行
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
list.add(t);
}
for (Thread thread : list) {
//让主线程等待自己创建的线程执行完毕,5个线程执行完 再取number的值
thread.join();
}
System.out.println("number=" + number); //值有多种可能,2677、4202、4722、4910、5000
}
}
分析:使用javap反汇编class文件(找到target下面的 类名.class 文件,cmd打开,控制台输入 javap -p -v .\Test02Atomicity.class),得到下面的字节码指令
其中对于number++而言(number为静态变量),实际会产生如下的JVM字节码指令:
9: getstatic #18 // Field number:I
12: iconst_1
13: iadd
14: putstatic #18 // Field number:I
number++执行过程为:
- 9: getstatic —— 获取number的值
- 12: iconst_1 —— 准备常量1
- 13: iadd —— 让number与1相加
- 14: putstatic —— 给number赋值
以线程1、线程2 两个线程为例,当线程1走到iadd
值为1但暂未赋给number,线程2也走到 iadd
。此时两个线程无论谁先执行putstatic
,最终number的值均为1。因为number++的4条语句没有保证原子性 导致数据出现错误。
小结:并发编程时,会出现原子性问题,当一个线程对共享变量操作到一半时,另外的线程也有可能来操作共享变量,干扰了前一个线程的操作。
1.3 有序性(Ordering)
概念:指程序中代码的执行顺序。我们一般认为,编写代码的顺序 就是程序最终的执行顺序,这种说法不对。Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。
演示:
jcstress是java并发压测工具,使用方法如下:
1)修改pom文件,添加依赖
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>0.14</version>
</dependency>
2)编写代码 TestOrdering.java
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;
@JCStressTest //用并发压缩工具jcstress 测试类方法
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok") //@Outcome 对输出结果进行处理, 1 4 打印ok,0 打印danger
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class TestOrdering {
int num = 0;
boolean ready = false;
//线程1执行的代码
@Actor //@Actor 表示有多个线程来操作这两个方法
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
//线程2执行的代码 对两个变量进行修改
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
3)说明:
I_Result 是一个对象,有一个属性 r1 用来保存结果,在多线程情况下可能出现几种结果?
- 情况1:1。线程1先执行actor1,这时ready = false,所以进入else分支结果为1
- 情况2:4。线程2执行到actor2,执行了
num = 2
和ready = true
,线程1执行,这回进入 if 分支,结果为4 - 情况3:1。线程2先执行actor2,只执行num = 2;但没来得及执行 ready = true,线程1执行,还是进入else分支,结果为1
- 情况4:0。java对actor2方法重排序,先
ready=true
后num=2
。线程2先执行actor2,只执行ready=true
,但没来得及执行num=2
(此时num仍为0),线程1执行,进入if分支,结果为0
@Actor
public void actor2(I_Result r) {
ready = true;
num = 2;
}
4)执行过程:idea打开Terminal,输入 mvn clean install
mvn clean install成功后,在terminal控制台执行 java -jar target/jcstress.jar,可以获取相应的并发压测结果。发现其中确实出现 0、1、4这三种结果,其中0的占比为0.09%(java对actor2方法重排序)。
注:如果mvn clean install 无法生成jcstress.jar包,可在pom文件中加入以下插件,重新mvn clean install,即可生成jcstress.jar
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.2</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.2</version>
</dependency>
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>0.14</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<!-- jar生成路径 -->
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.2</version>
<executions>
<execution>
<id>main</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<!-- 打包的名字 jcstress.jar -->
<finalName>jcstress</finalName>
<!-- 调用下面两个类 -->
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jcstress.Main</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/TestList</resource>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
小结:程序代码在执行过程中的先后顺序,由于Java在编译期以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序。
二、Java内存模型(JMM)
在介绍Java内存模型之前,我们先看一下 计算机内存模型。
2.1 计算机结构
学习计算机的主要组成、以及缓存的作用。
2.1.1 计算机结构简介
冯诺依曼提出计算机由五大组成部分,分别为:输入设备、输出设备、存储器、控制器、运算器。
2.1.2 CPU
中央处理器,是计算机的控制和运算的核心,我们的程序最终都会变成指令让CPU去执行,处理程序中的数据。
2.1.3 内存
我们的程序都是在内存中运行的,内存会保存程序运行时的数据,供CPU处理。
2.1.4 缓存
CPU的运算速度和内存的访问速度相差比较大。这就导致CPU每次操作内存都要耗费很多等待时间。内存的读写速度成为了计算机运行的瓶颈。于是就有了在CPU和主内存之间增加缓存的设计。最靠近CPU的缓存称为L1,然后依次是 L2,L3和主内存,CPU缓存模型如图下图所示。
CPU Cache分成了三个级别: L1, L2, L3。级别越小越接近CPU,速度也更快,同时也代表着容量越小。
- . L1是最接近CPU的,它容量最小,例如32K,速度最快,每个核上都有一个L1 Cache。
- L2 Cache 更大一些,例如256K,速度要慢一些,一般情况下每个核上都有一个独立的L2 Cache。
- L3 Cache是三级缓存中最大的一级,例如12MB,同时也是缓存中最慢的一级,在同一个CPU插槽之间的核共享一个L3 Cache。
Cache的出现是为了解决CPU直接访问内存效率低下问题的,程序在运行的过程中,CPU接收到指令后,它会最先向CPU中的一级缓存(L1 Cache)去寻找相关的数据,如果命中缓存,CPU进行计算时就可以直接对CPU Cache中的数据进行读取和写人,当运算结束之后,再将CPUCache中的最新数据刷新到主内存当中,CPU通过直接访问Cache的方式替代直接访问主存的方式极大地提高了CPU 的吞吐能力。但是由于一级缓存(L1 Cache)容量较小,所以不可能每次都命中。这时CPU会继续向下一级的二级缓存(L2 Cache)寻找,同样的道理,当所需要的数据在二级缓存中也没有的话,会继续转向L3 Cache、内存(主存)和硬盘。
2.1.5 小结
冯-诺依曼计算机的特点
- 计算机由五大部分组成:运算器、控制器、存储器、输入设备、输出设备
- 指令和数据以同等地位存于存储器,可按地址寻访
- 指令和数据用二进制表示
- 指令由操作码和地址码组成
- 存储程序
- 以运算器为中心
2.2 Java内存模型(JMM)
2.2.1 Java内存模型(JMM)的概念
Java Memory Molde (Java内存模型/JMM),千万不要和Java内存结构混淆。
关于“Java内存模型”的权威解释,请参考 https://download.oracle.com/otn-pub/jcp/memory_model-1.0-pfd-spec-oth-JSpec/memory_model-1_0-pfd-spec.pdf。
Java内存模型,是Java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,具体如下。
- 主内存:主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
- 工作内存:每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量。
2.2.2 Java内存模型的作用
Java内存模型是一套在多线程读写共享数据时,对共享数据的可见性、有序性、和原子性的规则和保障。
synchronized, volatile
2.2.3 CPU缓存、内存与Java内存模型的关系
通过对前面的CPU硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行。
但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存和主内存之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。
JMM内存模型与CPU硬件内存架构的关系:
2.2.4 小结
Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。
2.3 主内存与工作内存之间的交互
Java内存模型中定义了以下8种操作来完成,主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。对应流程图如下:
Read——读取共享变量x;Load——将共享变量加载到工作内存中;Use——线程对共享变量x副本进行操作;Assign——操作得到新结果,赋值给x副本;Store——保存共享变量x副本及新值;Write——将最新值同步回主内存中。
Lock、Unlock——锁操作,与锁有关,譬如加了synchronized 才会有lock、unlock;如果共享变量没有加锁,则没有lock、unlock
注意:
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中
主内存与工作内存之间的数据交互过程:
lock -> read -> load -> use -> assign -> store -> write -> unlock
三、synchronized保证三大特性
synchronized能够保证在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。
synchronized (锁对象) {
// 受保护资源;
}
3.1 synchronized与可见性
使用synchronized保证可见性
针对1.1中的可见性问题(当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改后的最新值),有两种方案可以解决:volatile、synchronized
- 加volatile:给共享变量加上volatile,当修改的值同步回主内存时,有个缓存一致性协议,会把其他工作内存的值全部设置为失效,线程会重新读取共享内存的值。(此处不详细讨论volatile,只展示简单用法)
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
案例演示: 一个线程对共享变量的修改,另一个线程不能立即得到最新值
*/
public class Test01Visibility {
private final static Logger LOG = LoggerFactory.getLogger(Test01Visibility.class);
//1.创建一个共享变量,
//加volatile:当修改的值同步回主内存,有个缓存一致性协议,会把其他工作内存的值全部设置为失效,线程会重新读取共享内存的值
private static volatile boolean run = true;
public static void main(String[] args) throws InterruptedException {
//2.创建一个线程不断读取共享变量
new Thread(() -> {
while(run) {
}
LOG.info("{} 读取run:{},线程结束", Thread.currentThread().getName(), run);
}, "Thread1").start();
Thread.sleep(1000);
//3.创建一个线程修改共享变量
new Thread(() -> {
run = false;
LOG.info( "{} 修改run值为:{}", Thread.currentThread().getName(), run);
}, "Thread2").start();
}
}
有两种输出结果。但无论是哪种,Thread1都能读取到最新run值、结束线程。
- 加synchronized:执行synchronized时,对应lock原子操作会刷新工作内存中共享变量的值,获取主内存中共享变量的最新值。
public class Test01Visibility {
private final static Logger LOG = LoggerFactory.getLogger(Test01Visibility.class);
private static boolean run = true;
private static Object obj = new Object();
public static void main(String[] args) {
//2.创建一个线程不断读取共享变量
new Thread(() -> {
while(run) {
synchronized (obj ) {
}
}
LOG.info("{} 读取run:{},线程结束", Thread.currentThread().getName(), run);
}, "Thread1").start();
//3.创建一个线程修改共享变量
new Thread(() -> {
run = false;
LOG.info( "{} 修改run值为:{}", Thread.currentThread().getName(), run);
}, "Thread2").start();
}
}
输出结果同volatile,表明Thread1能读取到最新run值、结束线程。
synchronized保证可见性的原理
小结
synchronized保证可见性的原理,执行synchronized时,对应lock原子操作会刷新工作内存中共享变量的值,获取主内存中共享变量的最新值。
3.2 synchronized与原子性
使用synchronized保证原子性
演示:5个线程各执行1000次 i++;
public class Test02Atomicity {
//1.定义一个共享变量number
private static int number = 0;
private static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
//2.对number进行1000的++操作
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
//需要锁对象,故创建一把锁
synchronized (obj) { //obj为锁对象,也可使用synchronized (Test02Atomicity.class)
number++; //synchronized保证 number++ 为原子操作。线程获取不到锁 就会等待
}
}
};
List<Thread> list = new ArrayList<>();
//3.使用5个线程来进行
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
list.add(t);
}
for (Thread thread : list) {
//让主线程等待自己创建的线程执行完毕
thread.join();
}
System.out.println("number=" + number); //5000
}
}
相比于1.2中的案例,仅在number++
外面添加了synchronized (obj)
代码。程序无论运行多少次,number的值均为5000,证明synchronized能保证原子性。【如果只给number加volatile关键字,无法保证原子性,仍可能输出多种不同的值。volatile对任意单个变量的读/写具有原子性,但是类似于i++这种复合操作不具有原子性】
分析:使用javap反汇编class文件(找到target下面的 类名.class 文件,cmd打开,控制台输入 javap -p -v Test02Atomicity.class),得到下面的字节码指令
有了synchronized同步代码块,当第一个线程执行到一半,就算切到第二个线程 第二个线程没有锁也进不来,能保证第一个线程执行4条指令的时候 不会收到干扰,即能保证同步代码块中的代码是原子操作。
与1.2中(无synchronized)的字节码指令做对比
synchronized使用监视器锁monitor保证原子性
synchronized保证原子性的原理
对number++
增加同步代码块后,保证同一时间只有一个线程操作number++;。就不会出现安全问题。
小结
synchronized保证原子性的原理,synchronized保证只有一个线程拿到锁,能够进入同步代码块。
3.3 synchronized与有序性
为什么要重排序
为了提高程序的执行效率,编译器和CPU会对程序中代码进行重排序。但CPU和编译器不能瞎排序,需满足一些规则,即as-if-serial语义。
as-if-serial语义
as-if-serial语义的意思是:不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的。注意,重排序只能保证单线程情况下结果的正确性,多线程结果可能有问题。
我们看一下,什么时候能进行重排序、什么情况下不能进行重排序。
以下数据有依赖关系,不能重排序:写后读、写后写、读后写
//写后读
int a = 1;
int b = a;
//写后写
int a = 1;
int a = 2;
//读后写 如果重排序将 b=a放到最后,b的值可能会不同,1、2
int a = 1;
int b = a;
int a = 2;
编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
int a = 1;
int b = 2;
int c = a + b;
上面3个操作的数据依赖关系如图所示:
a和c之间存在数据依赖关系,同时b和c之间也存在数据依赖关系。因此在最终执行的指令序列中,c不能被重排序到a和b的前面。但a和b之间没有数据依赖关系,编译器和处理器可以重排序a和b之间的执行顺序。下图是该程序的两种执行顺序。
//可以这样
int a = 1;
int b = 2;
int c = a + b;
//也可重排序为如此,两种方式操作的结果相同
int b = 2;
int a = 1;
int c = a + b;
使用synchronized保证有序性
针对1.3中的有序性问题(由于Java在编译期以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序),有两种方案可以解决:volatile、synchronized
另起一个类,复制1.3中的代码。先把之前1.3中TestOrdering的注解全部注释掉,以防压力测试的时候也跑这份代码。
- volatile:给共享变量加上volatile,可以保证对应变量不发生重排序。(此处不详细讨论volatile,只展示简单用法)
@JCStressTest //用并发压缩工具jcstress 测试类方法
@Outcome(id = {"1"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = {"4"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger1")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class TestOrdering {
volatile int num = 0;
volatile boolean ready = false;
@Actor //@Actor 表示有多个线程来操作这两个方法
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
每一轮可能出现1、4,但不会出现0,证明volatile能保证有序性
- synchronized:
@JCStressTest //用并发压缩工具jcstress 测试类方法
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok") //@Outcome 对输出结果进行处理, 1 4 打印ok
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class TestOrdering {
private static Object obj = new Object();
int num = 0;
boolean ready = false;
//线程1执行的代码
@Actor //@Actor 表示有多个线程来操作这两个方法
public void actor1(I_Result r) {
synchronized (obj) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
}
//线程2执行的代码 对两个变量进行修改
@Actor
public void actor2(I_Result r) {
synchronized (obj) {
num = 2;
ready = true;
}
}
}
操作方法同1.3。发现本次输出结果与之前不一样,也不会出现0,证明synchronized能保证有序性。
如果想看输出结果,可将@Outcome注解为以下。重新执行,即可查看压力测试结果。发现每一轮可能出现1、4,但不会出现0,因此synchronized能保证有序性。
@JCStressTest
@Outcome(id = {"1"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = {"4"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger1")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class TestOrdering {
//...
}
synchronized保证有序性的原理
加入synchronized后,虽然进行了重排序,保证只有一个线程会进入同步代码块,也能保证有序性。
使用javap反汇编class文件(找到target下面的 类名.class 文件,cmd打开,控制台输入 javap -v -p TestOrdering.class),得到下面的字节码指令。
当java对actor2方法重排序,先ready=true
后num=2
。就算线程2先执行actor2,只执行ready=true
,但没来得及执行num=2
(此时num仍为0),cpu切给其他线程。其他线程没锁,也无法进入actor1方法,保证了有序性。
小结
synchronized保证有序性的原理,我们加synchronized后,依然会发生重排序,只不过我们有同步代码块,可以保证只有一个线程执行同步代码中的代码。保证有序性
四、synchronized的特性
4.1 可重入特性(针对同一个线程)
了解什么是可重入、以及可重入的原理
4.1.1 什么是可重入
- 可重入锁是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁
- 如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住
一个线程可以多次执行synchronized,重复获取同一把锁。
/*
目标:演示synchronized可重入
1.自定义一个线程类
2.在线程类的run方法中使用嵌套的同步代码块
3.使用两个线程来执行
*/
public class Demo01 {
public static void main(String[] args) {
new MyThread().start();
new MyThread().start();
}
}
//1.自定义一个线程类
class MyThread extends Thread {
@Override
public void run() {
synchronized (MyThread.class) {
System.out.println(getName() + "进入了同步代码块1");
synchronized (MyThread.class) {
System.out.println(getName() + "进入了同步代码块2");
}
}
}
}
说明同一个线程可以进入多个同步代码块,拿到同一把锁。锁里面有一个计数器,记录自己被拿几次,结束同步代码块会释放锁 计数器会减1。
上面演示的情况比较简单,两个同步代码块嵌套。我们也可以不用两个同步代码块嵌套,把它放到同一个类的两个不同方法中;也可放到其他类的方法中,以下两种输出结果同上。说明锁重入跟调用哪个对象、哪个方法无关,主要看的是线程、锁。
//同一个类的两个不同方法
public class Demo01 {
public static void main(String[] args) {
new MyThread().start();
new MyThread().start();
}
}
class MyThread extends Thread {
@Override
public void run() {
synchronized (MyThread.class) {
System.out.println(getName() + "进入了同步代码块1");
//锁重入跟调用哪个对象、哪个方法无关,主要看哪个线程、哪个锁
test01();
}
}
//方式二 放同一个类的其他方法中
public void test01() {
synchronized (MyThread.class) {
System.out.println(getName() + "进入了同步代码块2");
}
}
}
//其他类的方法
public class Demo01 {
public static void main(String[] args) {
new MyThread().start();
new MyThread().start();
}
//方式三 放其他类的方法中
public static void test01() {
synchronized (MyThread.class) {
String name = Thread.currentThread().getName();
System.out.println(name + "进入了同步代码块2");
}
}
}
class MyThread extends Thread {
@Override
public void run() {
synchronized (MyThread.class) {
System.out.println(getName() + "进入了同步代码块1");
//锁重入跟调用哪个对象、哪个方法无关,主要看哪个线程、哪个锁
Demo01.test01();
}
}
}
4.1.2 可重入原理
synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁。
4.1.3 可重入的好处
- 可以避免死锁
- 可以让我们更好的来封装代码。在同步代码块中可以调用另外一个方法,该方法也含有同步代码块,方便我们用方法进行封装
4.1.4 小结
synchronized是可重入锁,内部锁对象中会有一个计数器记录线程获取几次锁,在执行完同步代码块时,计数器的数量会-1,直到计数器的数量为0,就释放这个锁。
4.2 不可中断特性
了解习synchronized不可中断特性、习Lock的可中断特性
4.2.1 什么是不可中断
一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。
4.2.2 synchronized不可中断演示
/*
目标:演示synchronized不可中断
1.定义一个Runnable
2.在Runnable定义同步代码块
3.先开启一个线程来执行同步代码块,保证不退出同步代码块
4.后开启一个线程来执行同步代码块(阻塞状态)
5.停止第二个线程
*/
public class Demo02_Uninterruptible {
private static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
//1.定义一个Runnable
Runnable run = () -> {
//2.在Runnable定义同步代码块
synchronized (obj) {
String name = Thread.currentThread().getName();
System.out.println(name + "进入同步代码块");
//保证不退出代码块
try {
Thread.sleep(888888);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
//3.先开启一个线程来执行同步代码块,保证不退出同步代码块
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
//4.后开启一个线程来执行同步代码块(阻塞状态)
Thread t2 = new Thread(run);
t2.start(); //t2处于阻塞状态
//5.停止第二个线程
System.out.println("停止线程前");
t2.interrupt();
System.out.println("停止线程后");
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
查看结果,t2仍处于BLOCKED状态,我们使用t2.interrupt()
强行中断 并未成功。由于synchronized不可中断,处于阻塞状态的线程无法被中断、会一直等
4.2.3 ReentrantLock可中断演示
ReentrantLock有两种方式,一种可中断、一种不可中断。
- ReentrantLock的lock()方法和synchronized一样,不可被中断,即一个线程已经获得了锁,其他线程就需要一直等待下去,不能中断,直到获得到锁才运行。
- 通过reentrantlock.lockInterruptibly();可以通过调用阻塞线程的t1.interrupt();方法打断。
测试使用lock.lock()不可以从阻塞队列中打断, 一直等待别的线程释放锁
/*
目标:演示Lock不可中断和可中断
*/
public class Demo03_Interruptible {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
// test01();
test02();
}
//演示Lock不可中断 lock()方法
public static void test01() throws InterruptedException {
Runnable run = () -> {
String name = Thread.currentThread().getName();
try {
lock.lock(); //该方式不可被中断,lock()方法无返回值
System.out.println(name + "获得锁,进入锁执行");
Thread.sleep(88888);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); //释放锁,如果不释放其他线程就获取不到锁
System.out.println(name + "释放锁");
}
};
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(run);
t2.start();
System.out.println("停止t2线程前");
t2.interrupt();
System.out.println("停止t2线程后");
Thread.sleep(1000);
System.out.println(t1.getState());
System.out.println(t2.getState());
}
//Thread-0获得锁,进入锁执行;Thread-1在指定时间没有得到锁,做其他操作
//演示Lock可中断
public static void test02() throws InterruptedException {
Runnable run = () -> {
String name = Thread.currentThread().getName();
boolean b = false;
try {
//tryLock在指定时间内 会尝试能否得到锁,能得到 返回true,不能得到 返回false
b = lock.tryLock(3, TimeUnit.SECONDS);
if (b) {
System.out.println(name + "获得锁,进入锁执行");
Thread.sleep(88888);
} else {
System.out.println(name + "在指定时间没有得到锁,做其他操作");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (b) {
lock.unlock();
System.out.println(name + "释放锁");
}
}
};
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(run);
t2.start();
// System.out.println("停止t2线程前");
// t2.interrupt();
// System.out.println("停止t2线程后");
//
// Thread.sleep(1000);
// System.out.println(t1.getState());
// System.out.println(t2.getState());
}
}
执行test01(),发现t2不可被中断。t1已获得锁,t2线程就需要一直等待下去,不能中断,直到获得锁才运行
过了一会输出
执行test02()
过了一会儿输出
4.2.4 小结
不可中断是指,当一个线程获得锁后,另一个线程一直处于阻塞或等待状态,前一个线程不释放锁,后一个线程会一直阻塞或等待,不可被中断。 synchronized属于不可被中断,Lock的lock方法是不可中断的,Lock的tryLock方法是可中断的