- Java内存模型
- 原子性
- 可见性
- 有序性
- volatile
- 可见性:内存语义
- 有序性:内存屏障
- synchronized
- 两种加锁方式
- 实例加锁:synchronized(this) / 普通方法
- 类对象加锁:synchronized(this.class) / 静态方法
- synchronized底层原理:Monitor
- monitorenter / monitorexit / ACC_SYNCRONIZED
- C++结构体:Entry_list,Wait_List,Owner
- 对象头
- 锁
- 其他优化:自旋,锁粗化,锁消除
- 偏向锁
- 轻量级锁
- 重量级锁
3.1 JMM内存模型(Java Memory Model)
定义:Java内存模型是一种抽象的概念,它定义了Java虚拟机(JVM)在计算机内存中的工作方式,包括变量的访问规则、线程间的通信机制以及内存的同步原则等。
目的:它的主要目的是为了解决多线程环境中的并发问题,确保程序在不同的硬件和操作系统平台上都能达到一致的内存访问效果
JMM的主要组成:
- 主内存(Main Memory):所有的线程共享主内存,它包含了Java程序中的实例字段、静态字段和构成数组的元素
- 工作内存(Working Memory):每个线程都有自己的工作内存,它包含了线程用于读写操作的变量副本
内存模型的特性:
- 原子性
- 定义:一个操作是不可分割的,即使是在多线程同时执行的环境中,一个操作要么完全执行,要么完全不执行,不会出现只执行一部分的情况
- 存在问题:对于 ++i 而言,如果时多线程操作,那么它并非原子的,因为 ++i 对应了四条字节指令码,不能保证这四条一起执行
- 解决方法:synchronized关键字
- 可见性
- 定义:一个线程对共享变量的写操作,对其他线程的读操作是可见的
- 存在问题:下图代码中,因为t要频繁的从主存中读取run的值,JIT即时编译器会将run的值缓存到自己的高速缓存中,减少主存对run的访问,提高效率
-
static boolean run = true; public static void main(String[] args) throws InterruptedException{ Thread t = new Thread(()->{ while(run){ //... } }); t.start(); sleep(1); run = false; //线程t并不会如预想的停下来 }
-
- 解决方法:volatile关键字,synchronized关键字(成本相对较高)
- 有序性
- 定义:指程序执行的顺序按照代码的先后顺序执行
- 存在问题:在单线程环境下,程序的执行顺序似乎是一致的,但在多线程环境下,由于编译器优化和处理器优化,可能会出现指令重排序的现象,导致执行顺序与代码顺序不一致
- 解决方法:JMM用happens-before原则来保证指令的有序性,体现为volatile关键字
- 为什么synchronized不能保证:synchronized仅在一定程度上可以保证有序性,保证同步代码块中的代码不能和同步代码块外面的代码进行指令重排,在其内部还是会发生指令重排但基本不会影响结果
- 如果不加volatile,可能会引起指令重排:线程1进入同步代码块,正在执行new Singleton(),但是由于指令21和24重排,先执行了24(还未执行21),此时时间片走完切换到了线程2,线程2判断INSYANCE != null,实际上还未调用构造方法
-
//new指令对应以下四条字节码 17:new #3 20: dup 21: invokespecial #4 //调用构造方法new Singleton(),申请空间 24: putstatic #2 //赋值,把构造好的那块地址赋值给INSTANCE
- 例如:double-checked locking 懒汉单例模式
-
public final class Singleton{ private static volatile Singleton INSTANCE = null; private Singleton(){} public static Singleton getInstance(){ if(INSTANCE == null){ synchronized(Singleton.class){ if(INSTANCE == null) INSTANCE = new Singleton(); } } return INSTANCE; } }
- 现象:JVM会对指令进行指令重排来提高效率:在不改变结果的前提下,指令通过重排序和组合实现指令级并行
-
3.2 volatile
volatile关键字:保证可见性,有序性;不保证原子性
- 可见性(内存语义):如何保证可见性 ——> 强制读写主内存
- volatile的内存语义和synchronized相似之处,具体来说就是,当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile变量的值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)
- 进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量修改刷新到主内存
- 有序性:如何保证有序性 ——> 内存屏障 Memory Barrier(读写屏障:acquire barrier / release barrier)
- 对volatile读指令前加入读屏障,对volatile写指令后会加入写屏障。写屏障之前的代码不会被重排到写屏障后,读屏障之后的代码不会重排到屏障之前
3.3 synchronized
synchronized关键字:保证原子性,可见性;不完全保证有序性
- 两种种加锁方式
- 1. 加在普通方法上:等效于synchronized(this) ——> 本质是锁 this实例对象
- 2. 加在静态方法上:等效于synchronized(this.class) ——> 本质是锁 类对象
- 面向对象的改进:
-
class Room{ private int counter = 0; public void increment(){ synchronized(this){ counter++; } } public void decrement(){ synchronized(this){ counter--; } } public int getCounter(){ synchronized(this){ return counter; } } } 使用时: Room room = new Room(); room.increment; room.decrement;
3.4 synchronized底层原理:Monitor
- synchronized(this)的底层原理:在进入前和进入后加 monitorenter 和 monitorexit 字节码
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
- synchronized加在方法上:方法上加 ACC_SYNCHRONIZED 标示符
- monitor执行原理:当升级为 重量级锁 时,会将对象头MarkWord的前30bit指向一个 Monitor 对象,该对象是一个JVM中的C++对象
ObjectMonitor() {
_header = NULL;
_count = 0; //线程获取管程锁的次数
_waiters = 0, //处于等待状态的线程数
_recursions = 0; //管程锁的重入次数
_object = NULL;
_owner = NULL; //持有该ObjectMonitor的线程的指针
_WaitSet = NULL; //处于等待状态的线程队列(双向链表)
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //线程竞争管程锁时的队列(单向链表)
FreeNext = NULL ;
_EntryList = NULL ; //等待释放锁的BLOCKED_LIST
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
3.5 对象头
- 以32位虚拟机为例,对象头固定8个字节(8 bytes = 64 bits)
- 对象头 = MarkWord(存储该对象的信息,32bits) + KlassWord(指针指向该对象的类型,32bits)+ arraylength(可选,当该对象是数组时记录数组长度,32bits)
- 如何计算对象的大小
- Integer类大小 = 8 bytes(对象头) + 4bytes(int数值的大小) + 4bytes(32位自动补齐8的倍数) = 16 字节
- byte=1;short=2;int=4;long=8;float=4;double=8;boolean=1;char=2;
- 指针(引用)类型的大小 = 如果是32G内存以下的,默认开启对象指针压缩,4个字节
3.6 锁的加锁流程
- Reference
- 锁状态
- 轻量级锁:线程间上锁时间错开 ——> 关联栈帧Lock Record
- 重量级锁:某一线程占用锁,另一线程来竞争 ——> 关联Monitor对象
- 偏向锁:JVM默认自动开启,有其他线程同时来竞争,撤销转轻量级锁
- 概念
- 锁膨胀:指轻量级锁膨胀成重量级锁
- 锁重入:锁重入现象
- 自旋锁(优化):一种优化,竞争重量级锁之前会自旋
- 锁撤销:一般指偏向锁撤销
- 锁消除(优化):JIT优化
- 轻量级锁:适用于线程之间没有发送竞争(各个线程对锁的访问时间是错开的)
- 1. 线程观察对象头,发现锁标志位是 001(无锁状态),开始加 轻量级锁(假如不考虑偏向锁的存在)
- 2. 加锁流程
- 2.1 加锁:栈帧创建锁记记录:执行synchronized(Object)时
- 锁记录包含:(1)该锁记录的地址 + 00(轻量级锁标识符) ;(2)object reference指向synchronized对象
- 2.2 加锁:CAS交换 + 指向该对象:执行完synchronized(Object)后
- 2.1 当发现markword是无锁状态时,尝试用CAS交换 Lock Record 与 Mark Word
- 2.2 object reference指向该object对象
- 2.1 加锁:栈帧创建锁记记录:执行synchronized(Object)时
- 3. 加锁结果判断
- 3.1 CAS执行成功 : 轻量级锁加锁成功,结束
- 3.2 CAS执行失败 :发现Object对象的Mark Word已经变了
- (1)变成了锁记录,且这个记录是自己这个线程的:锁重入(见下面)
- (2)变成了锁记录,且这个记录是属于其他线程的(说明在CAS过程中其他线程先抢占了轻量级锁):这个时候就有了竞争,进入锁膨胀过程,轻量级锁转重量级锁(见下面)
- (2)变成了一个Monitor地址(可能这个CAS太慢了,其他两个线程竞争完了并加完了重量级锁):自旋一阵子后,进入该Monitor的Entry_list(阻塞队列)
- 4. 解锁流程:退出synchronized块时,栈帧里的锁记录会尝试再用CAS把对象头里面的信息换回来
- 4.1 栈帧锁记录 为 null:重入锁 ——> 直接释放,删除锁记录
- 4.2 栈帧锁记录不为null:
- 对象头的Mark Word为自己的Lock Record:CAS交换回来
- 对象头的Mark Word为 Monitor地址(说明在自己使用的过程中其他线程进行了竞争,并且把轻量级锁换成了重量级锁 ):锁已经膨胀了变成了重量级锁,去Monitor中,置owner为null,唤醒blocked线程
- 锁重入:前面讲到 加轻量级锁尝试CAS 时,发现Obejct对象的Mark Word已经变成了 Lock Record,并且发现该 Lock Record 的地址属于自己这个线程,开始锁重入
- 重入锁加锁:
- 栈帧再加一个 Lock Record对象,内容为null(表示重入锁),Object Reference继续指向该Object
- 重入锁解锁:和轻量级解锁过程一样
- 重入锁加锁:
- 重量级锁:当两个线程同时竞争一个轻量级锁的时候,CAS失败的那个会把轻量级锁转重量级锁(也称锁膨胀)
- 加重量级锁过程(假设此时T1线程占有了轻量级锁,T2线程尝试加锁,进行锁膨胀)
- 1. 申请一个Monitor对象
- 2. 把对象头的Mark Word的位置填入该Monitor的地址(原来这位置存储着T1的锁记录,该锁记录会被放到Monitor对象中,可以理解为Owner会变成T1)
- 3. 让原来T1中的锁记录指向该Monitor对象
- 4. 不考虑自旋:T2自己进入Monitor对象的Entry_list(阻塞队列)等待
- 解锁流程:T1退出同步代码块时发现该对象头的Mark Word为 Monitor地址(说明在自己使用的过程中其他线程进行了竞争,并且把轻量级锁换成了重量级锁 ):锁已经膨胀了变成了重量级锁,T1去Monitor中,置owner为null,唤醒blocked线程
- 加重量级锁过程(假设此时T1线程占有了轻量级锁,T2线程尝试加锁,进行锁膨胀)
- 优化:自旋锁(竞争重量级锁时)
- 场景发生在:当锁资源被占用的情况下,Monitor对象中的Entry List线程不用马上进入堵塞队列,而是进入自旋状态,简单可以理解为在做循环试探锁资源是否被释放了,目的是达到锁资源一释放就可以立马被下一个线程使用,不要再去进行唤醒操作
- 备注:
- 自旋会占用CPU的资源,如果是单核CPU就会存在很大的浪费,所以自旋适用于多核的CPU
- 自适应性:Java 7之后就不能手动控制是否开启自旋功能了,而是由JVM自动执行,并且是自适应的,例如如果一次自旋成功,就会被认为自旋成功的可能性大,就会多自旋几次,反之,少自旋或者不自旋,设计的比较智能
- 偏向锁(轻量级锁的优化,优化了CAS重入)
- 优化场景
- 加轻量级锁时,第一次对Object对象上锁的过程中会有一次cas操作,如果要对Object对象第二次上锁,则会cas失败(重入),此时锁记录指针会指向null,并且操作系统会创建一个新的栈帧存储这一个锁记录,依次类推,如果这个Object对象被重复n次,则会生成n个这样的记录,作为锁的可重入的计数,n次CAS很消耗CPU资源,可以优化
- 具体操作:
- 第一次进行cas操作的时候,将线程ID设置到Mark Word头部(而不再是Lock Record),此后检查发现这个线程ID是自己,接下来就都不用进行cas操作了,以后只要不竞争,这个对象就归改线程所拥有
- 锁撤销(偏向锁撤销)的几种情况
- JAVA6之后,引入偏向锁。
- 默认开启偏向锁。创建对象时,Mark word的最后三位为101,其它的thread、epoch、age 都为 0(用到再赋值)
- 以下几种情况,有引起偏向锁撤销
- ①如果加了偏向锁(thread有值了),再调用hashcode()方法,会撤销偏向锁
- ②偏向锁只适用于1个线程重入。即使t1和t2两个线程上锁时间互不影响没有竞争,t2上锁后,就会把t1的偏向锁撤销掉,换成轻量级锁,原来的偏向锁的线程id也会被换成锁记录
- ③调用wait(),notify()方法时,无论该对象的锁是处于偏向锁状态还是其他状态,都必须先撤销偏向锁(如果有的话),然后将锁升级为重量级锁。这是因为wait需要将线程放入等待队列中,而这个操作是与轻量级锁和偏向锁的设计目标不兼容的
- ④当撤销偏向锁的偏向的阈值达到40时,再创建新的对象,它都是不可偏向的,也就是无锁状态
- 优化场景
- 锁消除(JIT优化)
- 如果JIT认为某段synchronized代码块是线程安全的,会自己去除synchronized关键字
- JVM源码的锁逻辑