(JVM)我们该如何认识 Java的内存模型(Java Memory Model(JMM))? 本篇文章告诉你答案 !带你全面了解JMM
1. Java 内存模型(Java Memory Model(JMM))
JMM 定义了一套在线多线程读写共享数据实(成员变量、数组)时,对数据的可见性、有序性和原子性的规则和保障
1.1 原子性
原子性(Atomic)就是不可分割的意思,是指在进行一系列操作的时候这些操作要么全部执行要么全部不执行,不存在只执行一部分的情况。
原子操作的不可分割有两层含义:
- 访问(读、写)某个共享变量的操作从其他线程来看,该操作要么已经执行要么尚未发生,即其他线程看不到当前操作中的中间结果
- 访问同一组共享变量的原子操作时不能相交错的。如现实生活中从ATM机取款。
1.1.1 怎么处理原子性问题?
java 给出的方案就是 synchronized(同步关键字)
synchronized(对象){
}
不过在使用synchronized时,会大大降低程序的并发性,谨慎使用
例:
package JMM;
public class demo1 {
static int c = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (obj) {
c++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (obj) {
c--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(c);
}
}
当代码中存在了synchronized同步关键字,monitor它会新建出三个线程Owner、Entrylist和waitSet
- 当t1还未被monitor指令锁定,那么Owner会将他锁定
- 当t2线程起来后,发现t1被锁定了,这时候就会进行EntryLsit中进行等待。
T1被Owner锁定,T2线程开始在EntryList区域等待
T1执行完毕,在EntryList中的T2线程开始被Owner锁定执行
1.1.2 通俗讲解:
可以将Obj想象成一个房间,线程t1、t2是两个人
当t1执行synchronized(obj)时就可以看成,t1进入了obj房间,然后锁住了这个门,在房间内执行代码
而在t1执行代码的期间,t2也执行synchronized(obj)了,它想进入obj房间,但是发现房间被锁住了,于是只能在门外找个凳子(EntryList)等待。
当t1执行完synchronized{}块内的代码后,它才会解开obj房间里门的锁,从obj房间出来。这时候t2线程才可以进入obj房间,反锁住门,执行它的代码
注意,不同线程进行synchronized同步时必须锁的是同一个对象,如果锁的不是同一个对象,就好比两个人分别进了不同的房间,达不到同步的效果
1.2 可见性
一个线程对主内存的修改可以及时的被其他线程观察到。
1.2.1 循环不退出
看一个例子:
main线程对run变量的修改对于t线程不可见,导致了t线程无法停止:
/**
* 可见性
*/
public class demo {
static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
System.out.println("测试");
}
});
t1.start();
Thread.sleep(1000);
flag = false;
}
}
使用的是JDK8环境,若是JDK11或以上则不会发生这种问题了
分析:
-
初始状态,t1线程刚开始从主内存中读取了run的值到工作内存
-
因为t1线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率
-
1秒后,main线程修改了run的值,并同步至主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
1.2.2 解决方法
- volatile (易变关键字)它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都直接操作主存
volatile static boolean flag = true;
tips:
synchronized语句块既可以保证代码的原子性,也同时保证块内变量的可见性。但缺点是synchronizad是属于重量级操作(降低并发能力),性能相对更低
1.3 有序性
package JMM;
/**
* 有序性
*/
public class demo2 {
int num = 0;
boolean ready = false;
static class Result{
int num = 0;
}
public void actor1(Result r){
if (ready){
r.num = num +num;
}else {
r.num = 1;
}
}
public void actor2(Result r){
num = 2;
ready = true;
}
}
Result是一个对象,有一个属性c用来保存结果。
两种运行情况:
- 情况一:线程1 先执行,执行完 ready = false,所以 else 分支结果为 1
- 情况二:线程2先执行num=2,但没执行ready=true,线程1执行,还是进入else分支,结果为1
- 情况三:线程2执行到ready=true,线程1执行,这回进入if分支,结果4(因为num已经执行过了)
- 情况二:
- 线程2执行ready=true后
- 线程1进入if分支,相加为0
- 线程2执行num=2
现象:指令重排
- 是JIT编译器在运行时的一些优化,这个现象需要通过大量测试才能复现
1.3.1 如何解决?
在做出关键性判断的一步上添加 volatile 关键字,保证线程之间对数值更改的可见性
volatile boolean ready = false;
1.3.2 有序性理解
同一个线程内,JVM会在不影响正确性的前提下,可以调整语句的执行顺序
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;// 一种较为耗时的操作
j = ...;
看得到是,不论先执行i
还是先执行j
对最终结果都不会产生影响。
这种特性被称之为【指令重排】,多线程下的【指令重排】会影响正确性,例如著名的double-checked looking
final class Singleton{
private Singleton (){}
private static Singleton INSTANCE=null;
public static Singleton getInstance(){
if (INSTANCE==null){
synchronized (Singleton.class){
if (INSTANCE==null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现特点是:
- 懒惰实例化
- 首次使用getInstance()才是哟ingsynchronized加锁,后续使用无需枷锁
但在多线程环境下,上面代码是有问题的,主要原因在于 INSTANCE = new Singleton()对应的字节码为:
17: new #3 // class JMM/demo3
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:LJMM/demo3;
其中 21、24两个步骤的顺序是不固定的,也许JVM会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程t1、t2按如下时间序列执行:
- t1 线程执行到 INSTANCE = new demo3();
- t1 线程分配空间,为demo3对象生成引用地址(0处)
- t1 线程将引用地址赋值给 INSTANCE,这时INSTANCE != null(7处)
- t2 线程进入getInstance() 方法,发现INSTANCE != null (synchronized块外),直接返回INSTANCE
- t1 线程执行 demo3的构造方法(4处)
这时候,t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的将是一个未初始化完毕的单例
解决办法:
-
对 INSTANCE 变量使用 volatile 关键字修饰即可。这种操作就可以禁用指令重排
tips:在JDK5以上的版本 volatile 才会真正有效
1.3.3 happens-before
happens-before 规定了哪些写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结
- 线程解锁m之前对变量的写,对于接下来对m加锁的其他线程对该变量的读可见
public class demo4 {
static int x;
static Object m = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (m) {
x = 10;
}
},"t1");
t1.start();
new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
},"t2").start();
}
}
- 线程对 volatile 变量的写,对接下来其他线程对该变量的读可见
- 线程stat前对变量的写,对该线程开始后对该变量的读可见
- 线程结束前对变量的写,对其他线程得知它结束后的读可见
- 例如:其他线程调用 t1.isAlive() 或者 t1.join() 等待它结束
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
- 对变量默认值(0,false,null)的写,对其他线程对该变量的读可见
- 具有传递性,如果 x hb->y && y hb->z 那么则有:x hb> z
2. CAS 原子类
2.1 CAS
CAS 即:Compare and Swap
它体现的一种乐观锁的思想,比如多个线程要对一个共享的整形变量执行+1 操作:
package JMM;
public class demo5 {
static int z = 0;
public static void main(String[] args) {
while (true) {
int o = z; // 比如拿到了当前值0
int n = o+1; // 在旧值 0 的基础上增加 1,正确结果是 1
/**
* 这时候如果别的线程把共享变量改成了 5,本线程的正确结果是 1 就作废了,这时候
* CAS 返回 false,重新尝试,直到:
* CAS 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
if (CAS(o,n)){
// 成功,退出循环
break;
}
}
System.out.println(z);
}
private static boolean CAS(int o, int n) {
z = n;
if (o == z){
return true;
}
return false;
}
}
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。
结合CAS和volatile可以实现无锁并发,适用于竞争不激烈、多核CPU的场景下。
- 因为没有使用 cynchronized,所以线程不会陷入阻塞,这时效率提示的因素之一
- 但如果竞争激烈,可以想到重试必然会频繁发生,反而效率会受影响
底层保护机制:
- CAS 底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令
例子:(直接使用Unsafe对象来进行线程安全保护)
package JMM;
import jdk.internal.misc.Unsafe;
import java.lang.reflect.Field;
public class demo6 {
public static void main(String[] args) throws InterruptedException {
DataContainer dc = new DataContainer();
int count = 5;
Thread t1 = new Thread(() -> {
for (int i = 0; i < count; i++) {
dc.increase();
}
});
t1.start();
t1.join();
System.out.println(dc.getData());
}
}
class DataContainer{
private volatile int data;
static Unsafe unsafe = null;
static long DATA_OFFSET = 0;
static{
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get("null");
}catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException(e);
}
try {
DATA_OFFSET = unsafe.objectFieldOffset(DataContainer.class.getDeclaredField("data"));
} catch (NoSuchFieldException e) {
throw new RuntimeException(e);
}
}
/**
* 相加
*/
public void increase(){
int oldValue;
// 当cas修改成功后,会返回true,那时候会结束循环,否则将一直循环尝试修改
while(true){
// 获得共享变量旧值
oldValue = data;
// cas 尝试修改data为【旧值 +1】;如果期间旧值被别的线程改了,则返回false
if (unsafe.compareAndSetInt(this,DATA_OFFSET,oldValue,oldValue+1)){
return;
};
}
}
/**
* 相减
*/
public void decrease(){
int oldValue;
while(true){
oldValue = data;
if (unsafe.compareAndSetInt(this,DATA_OFFSET,oldValue,oldValue-1)){
return;
};
}
}
public int getData(){
return data;
}
}
2.2 乐观锁与悲观锁
- CAS 是基于乐观锁的思想
- 最乐观的想法:不怕别的线程来修改共享变量,就算改了也没关系,大不了重新再尝试修改
- synchronized是基于悲观锁的思想:
- 最悲观的想法:需要时刻提防其他线程来修改共享变量,我上把锁,其他线程就别想改了!我改完再解锁,别人才有机会修改
2.3 原子操作类
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean等,它们底层就是采用CAS技术 +volatile 来实现的。
3. synchronized 优化
Java HotSpot 虚拟机中,每个对象都有对象头(包含class指针和Mark Word)。
Mark Word平时存储这个对象的哈希码、分代年龄,当加锁时,这些信息就根据情况被替换为标记位、线程锁记录指针、重量级锁指针、线程ID等内容
3.1 轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。而一旦出现并发情况,那么就会升级为重量级锁
举个简单的例子:
学生A(t1)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。
如果这期间有其他学生B(t2)来了,会告知学生A(t1)有并发访问,线程A t1 随机升级为重量级锁,进入重量级锁的流程
而重量级锁就不是用课本占座这种小操作了。可以想象成在座位周围用一个铁栅栏上了锁围起来
static Object obj new Object();
public static void method1(){
synchronized(obj){
// 同步块A
method2();
}
}
public static void method2(){
synchronized(obj){
// 同步块B
}
}
每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
线程A | 对象Mark Word | 线程B |
---|---|---|
访问同步块A,把Mark复制到线程A的锁记录 | 01(无锁) | |
CAS修改Mark为线程1锁记录地址 | 01(无锁) | |
成功(加锁) | 00(轻量锁)线程1锁记录地址 | |
执行同步块A | 00(轻量锁)线程1锁记录地址 | |
访问同步块B,把Mark复制到线程A的锁记录 | 00(轻量锁)线程1锁记录地址 | |
CAS修改Mark为线程A锁记录地址 | 00(轻量锁)线程1锁记录地址 | |
失败(发现是自己锁的) | 00(轻量锁)线程1锁记录地址 | |
锁重入 | 00(轻量锁)线程1锁记录地址 | |
执行同步块B | 00(轻量锁)线程1锁记录地址 | |
同步块B执行完毕 | 00(轻量锁)线程1锁记录地址 | |
同步块A执行完毕 | 00(轻量锁)线程1锁记录地址 | |
成功(解锁) | 01(无锁) | |
01(无锁) | 访问同步块A,把Mark复制到线程2的 | |
01(无锁) | CAS 修改Mark为线程2锁记录地址 | |
00(轻量级)线程2锁记录地址 | 成功(加锁) | |
… | … |
3.2 锁膨胀
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
static Object obj = new Object();
public static void method1(){
synchronized(obj){
// 同步块
}
}
线程A | 对象Mark Word | 线程B |
---|---|---|
访问同步块A,把Mark复制到线程A的锁记录 | 01(无锁) | |
CAS修改Mark为线程A锁记录地址 | 01(无锁) | |
成功(加锁) | 00(轻量锁)线程1锁记录地址 | |
执行同步块A | 00(轻量锁)线程1锁记录地址 | |
执行同步块A | 00(轻量锁)线程1锁记录地址 | 访问同步块A,把Mark复制到线程2的 |
执行同步块A | 00(轻量锁)线程1锁记录地址 | CAS 修改Mark为线程2锁记录地址 |
执行同步块A | 00(轻量锁)线程1锁记录地址 | 失败(发现其他线程已经占用锁) |
执行同步块A | 00(轻量锁)线程1锁记录地址 | CAS修改Mark为重量级锁 |
执行同步块A | 10(重量级)重置锁指针 | 阻塞中 |
执行完毕 | 10(重量级)重置锁指针 | 阻塞中 |
失败(解锁) | 10(重量级)重置锁指针 | 阻塞中 |
释放重量级锁,唤起阻塞线程竞争 | 10(重量级)重置锁指针 | 阻塞中 |
10(重量级)重置锁指针 | 竞争重量锁 | |
10(重量级)重置锁指针 | 成功(加锁) | |
… | … |
3.3 自旋重试
在重量级锁竞争时,还可以使用自旋来进行优化,如果当前线程自旋成功(即使现在持锁线程已经退出了同步块,释放了锁),这时候当前线程就可以避免阻塞
在java6以后,自旋锁时自适应的。比如对象刚刚的一次自旋操作成功过,那么任务这次自旋成功的可能性会比较高,就多自旋几次,反之就少自旋,甚至不自旋。
-
自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。
-
好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了更划算),熄火了相当于阻塞(等待时间长了更划算)
-
Java7之后不能控制是否开启自旋功能
3.3.1 自旋重试成功情况
线程1(cpu1上) | 对象Mark | 线程2(cpu2上) |
---|---|---|
10(重量锁) | ||
访问同步块,获取monitor | 10(重量锁)重置锁指针 | |
成功(加锁) | 10(重量锁)重置锁指针 | |
执行同步块 | 10(重量锁)重置锁指针 | |
执行同步块 | 10(重量锁)重置锁指针 | 访问同步块,获取monitor |
执行同步块 | 10(重量锁)重置锁指针 | 自旋重试 |
执行完毕 | 10(重量锁)重置锁指针 | 自旋重试 |
成功(解锁) | 10(重量锁) | 自旋重试 |
10(重量锁)重置锁指针 | 成功(加锁) | |
10(重量锁)重置锁指针 | 执行同步块 | |
… | … |
3.3.2 自旋重试失败情况
线程1(cpu1上) | 对象Mark | 线程2(cpu2上) |
---|---|---|
10(重量锁) | ||
访问同步块,获取monitor | 10(重量锁)重置锁指针 | |
成功(加锁) | 10(重量锁)重置锁指针 | |
执行同步块 | 10(重量锁)重置锁指针 | |
执行同步块 | 10(重量锁)重置锁指针 | 访问同步块,获取monitor |
执行同步块 | 10(重量锁)重置锁指针 | 自旋重试 |
执行同步块 | 10(重量锁)重置锁指针 | 自旋重试 |
10(重量锁)重置锁指针 | 阻塞 | |
… | … |
3.4 偏向锁
轻量级锁在没有竞争时,每次重入仍需要执行CAS操作。
java6中引入了偏向锁来做进一步优化;只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID时自己的,就表示没有竞争,不用重新CAS
在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。线程第二次到达同步代码块时,会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能是非常不错的。
唯独就怕遭其他线程抢锁,因为需要撤销偏向(会STW)。重复争抢锁,就会导致性能下降
缺点:
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的hashCode也会撤销偏向锁
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程t1的对象仍有机会重新偏向t2,重偏向会重置对象的Thread ID
- 撤销偏向和重偏向都是批量进行的,以类为单位
- 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
- 可以主动使用-XX:UseBiasedLocking禁用偏向锁
3.5 其他优化
3.5.1 减少上锁时间
同步代码块中尽量简短一点
3.5.2 减少锁的粒度
将一个锁拆分为多个锁提高并发度 例如:
- ConcurrentHashMap
- LongAdder 分为base和cells两部分。
- 没有并发竞争时候或者cells数组正在初始化的时候,会使用CAS来累加值到base;
- 有并发竞争时,则会初始化cells数组,数组有多少个cell,就允许有多少线程并行修改,最后将赎罪中每个cell累加,再加上base就是最终的值
- LinkedBlockingQueue入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
3.5.3 锁粗化
多次循环进入同步块不如同步块内多次循环
另外JVM可能会做如下优化,把多次append的加锁操作粗化一次(因为都是对同一个对象加锁,没比较重入多次)
new StringBuffer().append("a").append("b").append("c");
3.5.4 锁消除
JVM会进行代码的逃逸分析,例如某个加锁对象时方法内局部变量,不会被其他线程锁访问到,这时候就会被即时编译器忽略掉所有同步操作
3.5.5 读写分离
读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。
不论是使用哪一种读写分离具体的实现方案,想要实现读写分离一般包含如下几步:
- 部署多台数据库,选择其中的一台作为主数据库,其他的一台或者多台作为从数据库。
- 保证主数据库和从数据库之间的数据是实时同步的,这个过程也就是我们常说的主从复制。
- 系统将写请求交给主数据库处理,读请求交给从数据库处理
4. 😊👉前篇知识点
- 深入JAVA底层 JVM(Java 虚拟机)!带你认识JVM、程序计数器、JVM栈和方法栈还有堆内存!
- 在JVM中,类是如何被加载的呢?带你认识类加载的一整套流程!
- 带你一起研究JVM的语法糖功能 和 JVM的即时编译器
5. 💕好文相推
- 还不了解Git分布式版本控制器?本文将带你全面了解并掌握
- 带你认识依赖、继承和聚合都是什么!有什么用?
- 2-3树思想与红黑树的实现与基本原理
- !全网最全! ElasticSearch8.7 搭配 SpringDataElasticSearch5.1 的使用