- 上下文切换
- 如何减少上下文切换
- CAS算法
- ABA问题
- Java并发机制底层实现
- Volatile关键字
- 线程可见性
- 防止指令重排序
- volatie的实现
- 内存屏障
- 缓存行自动填充
- synchronized关键字
- synchronized底层实现
- java代码层级上
- 字节码层级上
- Java对象内存布局
- 对象头
- 锁状态
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
- 三种锁的比较
上下文切换
多线程是通过CPU给每个线程分配CPU时间片来实现的,所以本质上CPU只能执行一个线程而不能执行多个,多线程其实就是CPU在不断地切换线程去执行,让外界感觉像是多个在同时运行
多线程的情况下,CPU需要记录当前任务的状态才能切换到下一个任务,当再次切换到这个任务的时候,可以从记录的状态开始执行,加载进上次执行该任务的状态,从任务被切换再到被加载进来的过程,实际上就是一次上下文切换
如何减少上下文切换
- 少使用锁,使用锁会增加切换的次数,从而引起更多上下文的切换
- 使用CAS算法
- 使用协程
- 减少线程使用,线程越多,切换的次数越多,上下文切换越多
CAS算法
CAS是Compare And Swap的缩写,也就是比较和交换,当一个线程去修改一个值时,要先比对操作的值是否等于当前的值,如果不等于,代表有其他线程修改了,那么就要去进行重新获取和计算
ABA问题
CAS算法会有一个问题就是ABA问题,如果操作的值的确是等于当前的值,但当前的值是经过多个线程修改后又变回原来的,也就是仍然是被其他线程修改过
解决办法就是,在操作的值加上一个版本号,比较的时候同时也要比较这个版本号,当发生修改的时候,这个版本号要进行变化,比如说自增
Java并发机制底层实现
Volatile关键字
Volatile关键字有两个作用
- 线程可见
- 防止指令重排序
线程可见性
线程会将操作的变量都做一份复制,保存到本地内存中去,然后根据这份复制去进行操作的
多线程出现的问题在于各个线程之间不清楚操作的变量是否发生修改,因为都是根据据本地内存的副本去进行的,所以会发生,而volatie关键字实现了线程之间对变量的通信,让线程之间可以看到指定变量的情况
防止指令重排序
Java程序在new一个实例的时候(注意不是类加载的过程),步骤如下
- 在堆中划分内存
- 给对象属性加上默认值
- 给对象属性赋上初始值
- 让栈中的变量指向堆中为对象划分的内存,也就是引用赋值
前面两条是没有问题的,但后面两条是可能会发生重排序的
当给对象属性赋上初始值的时候,如果这个操作耗时比较旧,CPU会先去执行后面的引用赋值的操作,这就是发生了指令重排序
指令重排序对于单线程来说是没有问题的,但对于多线程来说就会产生问题
这会导致的问题就是,当一个线程去实例化一个变量,此时发生了重排序,还没有初始值就有引用了(也就是不等于null),那么此时另一个线程去获取这个变量的时候,使用null去判断这个变量是否创建好的时候,就会出现问题(未初始化完成就可以进行获取)
volatile关键字可以防止指令发生重排序,也就一定要赋上初始值,才可以引用赋值,这样就解决了上面的问题了
volatie的实现
volatie的实现,其实本质上是一条汇编的lock指令
这个lock指令有两步
- 将当前处理器缓存行的数据写回到系统内存中
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
我们先来谈谈这个系统缓存和处理器的缓存行
处理器,也就是CPU,也可以理解成当前线程,他是不会直接和内存(主线程的变量都放在内存中)进行通信的,而是会先将系统内存的数据读取到内部缓存后再进行操作,操作的也是内部缓存
如果对volatie修饰的变量进行修改,那么第一步就是将这个变量所在缓存行的数据写回到系统内存,也就是修改,第二步就是告知其他CPU里缓存了该内存地址的数据无效,需要重新去获取
第二步的底层实现其实就是缓存一致性协议,每个处理器都会通过嗅探总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,需要重新从系统内存中把数据读取到处理器缓存中去
内存屏障
同时,volatile还要去实现防止重排序,而防止重排序的底层实现是内存屏障
在指令之间加上内存屏障,那么上下两条指令是不可以发生重排序的
现在我们来看看其字节码上的实现
也不知道为什么,window的javap -v命令无法显示变量的字节码情况
其实volatile的的字节码底层实现是加了一个acc_volatile修饰的,详细的执行过程就是上面所述
缓存行自动填充
上面提到过,volatile的实现是针对缓存行来进行操作的,而缓存行一般是64字节的,而往往一个变量可能不够8字节,那么就会导致一些不希望通知的数据也通知给了其他线程,这个数据也会被设置成无效状态(无效状态是整个缓存行无效),那么这个无关变量的使用效率就会降低,所以,我们需要缓存行填充,来让一个缓存行里面只有volatile变量和一些无关变量
synchronized关键字
synchronized在jdk1.6之前是一个重量级锁(通过操作系统的互斥量来实现的)
之后进行了一系列的优化,在有些情况下没有变得这么重了
前面复习过,synchronized是一个内部锁,又分为对象锁和类锁,也复习过synchronized可以加在方法上,也可以只锁住方法里面的一段代码块
- 对于类中的非静态方法,如果在方法上加锁,加的就是对象锁
- 对于类中的静态方法,如果在方法上加锁,加的就是类锁
- 对于方法块,加的是指定的锁,可以指定是类锁,也可以指定是对象锁
synchronized底层实现
我们看看synchronized是如何实现的
首先,我们这里要首先认识的是,synchronized的信息是放在Java对象头里面的
java代码层级上
java代码就是简单的加上synchronic关键字
字节码层级上
我这里创建了一个类,类中有两个加锁的方法,一个是方法上加锁,一个是代码块加锁
接下来看看字节码会怎样
通过比较不加锁的方法,即sayTwo
先看第一个加锁的方法,即say方法
通过跟不加锁的方法比较,方法加锁与方法不加锁唯一的不同就是在方法修饰上加了synchronized,但这并不是全部的,所以使用javap -v来看一下真实的
可以看到,底层是多了一个ACC_SYNCHRONIZED的修饰
现在看一下代码块
太长了,分两张来截
可以看到里面多了两个东西,MonitorEnter与MonitorExit
MonitorEnter指令是在编译后插入到代码块的开始位置的,而MonitorExit是插入到方法结束处和异常处的
任何对象都有自己的一个monitor与之关联,当这个monitor被持有后,这个对象就会处于锁定状态
当有线程执行到MonitorEnter指令后,会去尝试获取这个对象拥有的monitor的所有权,这一步相当于是获取对象的锁
JVM还规定每一个MonitorEnter都必须要有一个MonitorExit与之对应匹配
所以,代码块在字节码层级上实现的方式就是加MoniorEnter与MonitorExit,而方法加锁在字节码层级上的实现也是加了acc_synchronized来标识这个是一个同步方法
Java对象内存布局
锁的信息是存在哪里的,所以要去看一下Java对象的内存布局
Java对象的内存布局有三种
- 对象头
- markword:记录锁的信息
- 类型指针:标记属于哪一个class,对象是哪种类型
- 数组长度:如果该对象是一个数组,这里会记录数组长度
- 实例数据:即一些成员变量
- 对齐:java对象大小必须要可以被8整除,所以后面要进行对齐
对象头
synchronized用的锁其实就是存在Java对象头里面的,而且很明确存在于markword里面,而markword这部分占了8个字节,也就是64位,不过可能会进行压缩,从而变成4个字节,所以即可能是,64位也可能是32位,而整个Java对象头是12个字节,前面两个部分都可能会进行压缩,从8个字节变成4个字节
下面来看看markword的结构
锁状态 | 25bit(对象的hashcode) | 4bit(分代年龄) | 1bit(是否是偏向锁) | 2bit(标志位) |
锁状态
锁的状态一共有4种
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
无锁
无锁就是没有加锁
偏向锁
当只有一个线程时,即不会出现多线程竞争状态下,就是偏向锁状态
当一个线程访问加锁的方法或代码块,就会获得锁,获得锁的过程其实就是在markword里面记录自己的线程ID,那么以后该线程在进入和退出代码块时就不需要进行CAS自选来加锁和执行完后进行解锁,相当于这个锁只归该线程拥有
下面来看看偏向锁是怎样撤销的
偏向锁的撤销必须要等待全局安全点(在这个时间点上是没有正在执行的字节码的),首先会暂停拥有偏向锁的线程,然后去检查持有偏向锁的线程是否还存活着,如果线程已经不处于活动状态,则将markword设置成无锁状态;如果线程仍然存活,进行解锁(即将Markword里面记录的线程ID设为空),之后再恢复线程
- 暂停拥有偏向锁的线程
- 检查偏向锁的线程是否还存活
- 不存活就代表没有线程去抢这个锁,设置成无锁状态
- 存活就代表仍然会有线程去抢这个锁,此时将markword里面记录线程的ID设为空
- 恢复线程
轻量级锁
偏向锁是没有发生多线程竞争的,一旦发生了多线程竞争,就会变成轻量级锁
变成轻量级锁,必须要先撤销偏向锁,因为这个锁已经不再是一个线程专用的了(上面已经提到过偏向锁怎么撤销)
轻量级锁的获取过程其实就是CAS,当存在争夺锁的竞争就会变为轻量级锁
轻量级锁的加锁过程
- 线程首先会在自己的栈帧种创建用于存储锁记录的空间,并将对象头中的markword复制进来
- 在竞争过程中,线程会尝试使用CAS算法,去将对象的markword替换为指向自己锁记录的指针(即第一步复制进来的锁记录)
- 如果成功让对象的markword替换为指向自己锁记录的指针,就代表该线程争抢到这个锁了,可以执行
- 如果失败,则代表markword已经被其他线程设置了,当前线程便使用自旋来获取锁,即一段时间就过来看能否进行争抢
轻量级锁的解锁过程
当持有锁的线程完成操作就要进行解锁,解锁的步骤也是很简单
- 将对象的markword从执行自己锁记录的指针替换会原来的markword
重量级锁
当竞争越来越激烈时,轻量级锁就会升级成重量级锁,这是由于越来越多的线程会进入自旋状态,自旋也是要消耗CPU的,因为这个线程也是在运行着,降低执行效率,所以会去变成重量级锁,重量级锁的原理是操作系统的互斥量
当锁处于这个状态下,如果一个线程抢到了锁,那么其他线程都会被阻塞住,也就是被挂起,当持有锁的线程释放锁之后,才会去唤醒其他线程,被唤醒的线程会开始新一轮的抢锁。
三种锁的比较
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁不需要额外的消耗 | 如果,存在锁竞争,就要进行额外的锁撤销,即撤销偏向锁 | 只有一个线程,不存在竞争 |
轻量级锁 | 需要进行加锁和解锁,不过竞争的线程不会发生阻塞,提高了程序的响应速度 | 如果一个抢到锁的线程一直不归还锁,那么其他线程会一直自旋,消耗CPU | 有多个线程,其竞争情况不大,并且程序要追求响应时间,执行速度快 |
重量级锁 | 需要进行加锁和解锁,会发生阻塞,抢不到锁的线程会被挂起 | 线程会发生阻塞,响应时间缓慢 | 多个线程竞争十分激烈 |