多线程情况下,容易出现资源共享问题,此类安全问题发生的条件
- 多线程情况
- 涉及到共享的资源
- 对资源的操作非原子性
如下代码,
public class Sequence {
private int value;
public int getNext() {
return value ++;
}
}
Sequence s = new Sequence();
//多线程使用 s.getNext(),就会出现共享安全问题
new Thread(new Runnable() {
public void run() {
while(true) {
System.out.println(Thread.currentThread().getName() + " " + s.getNext());
}
}
}).start();
new Thread(new Runnable() {
public void run() {
while(true) {
System.out.println(Thread.currentThread().getName() + " " + s.getNext());
}
}
}).start();
value ++
是非原子性操作,解决此问题的方式,使用synchronized。
内置锁synchronized
锁的作用,在于借助被锁的对象,实现并发访问控制,这个被锁的对象,可以是共享资源对象,也可以是其他对象,比如上述例子,可以锁住value,也可以锁住Sequence,当然也可以是锁本身,比如Lock
锁的信息放在哪里?原则上可以锁住任何对象,锁信息放在被锁对象的对象头
中。
对象头的信息包括,
- Mark Word
- Class Metadata Address
- Array Length
其中,锁信息存放在Mark Word中,
synchronized使用范围
- 修饰对象方法,
- 修饰类方法
- 修饰代码块
/**
* synchronized 放在普通方法上,内置锁就是当前类的实例
* @return
*/
public synchronized int getNext() {
return value ++;
}
/**
* 修饰静态方法,内置锁是当前的Class字节码对象
* Sequence.class
* @return
*/
public static synchronized int getPrevious() {
return value --;
}
//作用块
public int test() {
synchronized (Sequence.class) { //锁对象,锁类都可以
if(value > 0) {
return value;
} else {
return -1;
}
}
}
对象锁,不同对象的锁不同,类锁,不同对象的锁相同。
synchronized的字节码语义,借助javap -verbose xx.class可以看到,如下,
synchronized字节码
可以看到有一个monitorenter和多个monitorexit(为了防止异常等,确保一定要exit),因此,synchronized是借助monitorenter和monitorexit来实现锁的控制,内置锁
在Java中被抽象为监视器锁(monitor),在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。在代码块或方法声明上添加synchronized关键字即可使用内置锁。
内置锁的自动优化
内置锁,在同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。随着JVM的升级,JDK6之后,几乎不需要修改代码,就可以直接享受JVM在内置锁上的优化成果。从简单的重量级锁,到逐渐膨胀的锁分配策略,使用了多种优化手段解决隐藏在内置锁下的基本问题。
重量级锁
使用场景,竞争激烈
synchronized最终的效果就是重量级锁,无论是否并发,每次遇到此关键词,都会加锁和减锁,实际情况,可能从来没有并发访问过资源,这样无意义的加锁和减锁,造成性能浪费,因此,synchronized最初就是重量级锁。
轻量级锁
使用场景,完全没有实际的锁竞争,计算是多线程,实际情况下,没有出现过竞争
使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。
Mark Word不做解释,对象头的一部分,Lock Record属于栈帧,每个线程都拥有自己的线程栈(虚拟机栈),记录线程和函数调用的基本信息
CAS自旋锁
CAS(Compare-and-Swap),即比较并替换,是一种实现并发算法时常用到的技术,将其用到锁中,操作如下,
- 当前线程竞争锁失败时,打算阻塞自己
- 不直接阻塞自己,而是自旋(空等待,比如一个空的有限for循环)一会
- 在自旋的同时重新竞争锁
- 如果自旋结束前获得了锁,那么锁获取成功;否则,自旋结束后阻塞自己
可以看到,如果在自旋的时间内,资源被老的owner释放掉了,当前线程就不需要阻塞自己,而是直接获得锁,减少线程切换了。
CAS自旋锁缺点
- 单核处理器上,不存在实际的并行,当前线程不阻塞自己的话(让出CPU使用权),旧owner就不能执行,锁永远不会释放,此时不管自旋多久都是浪费;进而,如果线程多而处理器少,自旋也会造成不少无谓的浪费。
- 自旋锁要占用CPU,如果是计算密集型任务,这一优化通常得不偿失,减少锁的使用是更好的选择。
- 如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。
使用-XX:-UseSpinning参数关闭自旋锁优化;-XX:PreBlockSpin参数修改默认的自旋次数
轻量级锁,拥有者和自旋锁一样的缺点,毕竟实现方式借助了CAS
偏向锁
使用场景,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗
轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS,注意,假定条件是一个线程,但凡发现竞争出现,则膨胀为轻量级锁。
偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(线程ID,本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
重温下Mark Word中的内容,更容易锁和后续流程图
- 线程ID
- Epoch
- 对象的分代年龄信息
- 是否是偏移锁
- 锁标志位
偏向锁缺点
适用于一个线程,如果发现竞争,则膨胀为轻量级锁,通过-XX:-UseBiasedLocking
可以禁止适用偏向锁,默认打开。
总结
场景对比
- 偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁。
- 轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争,可以自旋一会。
- 重量级锁:有实际竞争,且锁竞争时间长。
其他说明
- 内置锁只能沿着偏向锁、轻量级锁、重量级锁的顺序逐渐膨胀
配图
来自,https://blog.dreamtobe.cn/,https://www.jianshu.com/p/36eedeb3f912
网友评论