本文主要讲讲,在Java中关于锁的一些知识点,并介绍一下对锁进行的一些优化
一、前言
本文主要讲解Java中的锁和syncnized的一些知识,从我踏入社会开始,所接触的人都明确给我表示了项目中不能用syncnized,用syncnized都是外行诸如此类的话,那么syncnized到底能不能用?我的回答是可以用的,因为从jdk5以后,HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,如适应性自旋、锁消除、锁膨胀、轻量级锁、偏向锁等。
其实Java中的锁,大体上可以分为两种
1.阻塞锁
阻塞锁,也叫互斥锁,典型的就是syncnized、或者实现Lock接口的一些其他锁例如ReentrantLock,这些锁最明显的特征就是,当锁住之后,当一个线程持有锁,会阻塞其他所有线程,直到持有者释放锁,被阻塞的线程,就会进行内核态和用户态的切换(后面加入了自旋优化),非常消耗性能,所以互斥锁是一种悲观锁
2.非阻塞锁
非阻塞锁相反,它是一种乐观锁,JDK5以后,Java类库种支持CAS(Compare and Swap比较交换)操作来实现非互斥锁。
CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。
二、Syncnized和锁的底层原理
在Java中,最基本的锁就是syncnized,这是一种块结构锁,synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果
这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行
monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象
锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
所以综上所诉可以得到syncnized的两个特性:
- 可重入
- 当持有锁后,无条件阻塞其他线程
三、锁优化
1.自旋锁
- 自旋锁和自适应自旋锁分在一起来说,这是jdk6默认开启的一个优化,前面我们讨论互斥同步的时候,提到了互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。
- 那有没有一种方式不让线程被挂起呢?有,那就是自旋等待,当两个线程竞争同一个资源,没有竞争到的那个线程,就在原地自旋等待,等对面拿到用完之后释放锁,自己再去获得锁,不过这点有利有弊,如果对方线程占用锁的时间很短,那自旋的确能起到优化的作用,如果对面迟迟不释放锁,那线程的自旋就是浪费资源,所以自旋的次数很重要,默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改,如果自旋超过了这个次数,就会挂起线程。
- 而自适应自旋,就是系统自己去判断自旋次数多少,程序运行越久,对对象的监控就越准确,所以次数的设置就约恰当。
2.锁消除
锁消除是指虚拟机在进行即时编译的时候,发现一些同步代码,其中不会涉及到锁竞争,那么就会将锁消除掉。这其中的主要判断逻辑,就是逃逸分析,如果一段代码中,所有的数据都不会产生逃逸,那么这些数据就可以当做栈上的数据来对待,属于线程私有,加锁自然就无需进行。
java
public static void main(String[] args) {
int a = 1;
int b = 1;
int c;
synchronized (TestClass.class) {
c = a + b;
}
System.out.println(c);
}
比如这块带代码,synchronized 里面的所有操作,都不会逃逸到外部,加锁自然就没有必要。
你问我什么叫做逃逸?看下面这个方法init:
方法里面,我们创建一个对象userInfo,但是这个对象return了,那么就称userinfo产生了对象的逃逸,它可以被外部程序或者代码调用或者引用到
java
public UserInfo init() {
UserInfo userInfo = new UserInfo();
return userInfo;
}
再比如:init方法中的对象userInfo,通过参数传递的方式到了test方法,那么就称userInfo发生了参数逃逸。
java
public void init() {
UserInfo userInfo = new UserInfo();
test(userInfo);
}
public void test(UserInfo userInfo){
System.out.println(userInfo);
}
3.锁粗化
锁的粗化就是,当虚拟机查询到一段多次加锁的代码,可以扩大合并才一个锁的时候,会进行的优化操作,比如典型的StringBuffer里的append方法。
java
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("1");
stringBuffer.append("2");
stringBuffer.append("3");
相信大家也不陌生,每个append方法都是syncnized修饰的,所以虚拟机会将次代码优化为下面这种:
java
StringBuffer stringBuffer = new StringBuffer();
//伪代码,去掉所有append方法修饰符syncnized,外面统一包裹一层syncnized,大家知道意思就行。
synchronized (){
stringBuffer.append("1");
stringBuffer.append("2");
stringBuffer.append("3");
}
4.轻量级锁
轻量级锁也是jdk6以后加入的新机制,它的轻量级是相对于传统锁来说的,所以传统的锁也可以成为"重量级"。
要说到轻量级锁,需要我们知道对象的内存布局,HotSpot虚拟机的对象头分为两部分,一部分是运行时数据,如HashCode,分代年龄等,这部分数据在32位机器上会占用32bit,官方称它为"Mark Word"。另一部分存放的指向方法区内存数据的指针,如果是数组还会存个长度。
声明:以下图示内容引用周志明老师的《深入理解java虚拟机第三版》。
例如在32位的HotSpot虚拟机中,对象未被锁定的状态下,Mark Word的32个比特空间里的25个比特将用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,还有1个比特固定为0(这表示未进入偏向模式)。
轻量级锁的工作过程:
- 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为"01"状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,这时候线程堆栈与对象头的状态如图所示。
- 然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为"00",表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图所示。
- 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟
机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为"10",此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
- 上面描述的是轻量级锁的加锁过程,它的解锁过程也同样是通过CAS操作来进行的,如果对象的
Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的DisplacedMark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。轻量级锁能提升程序同步性能的依据是"对于绝大部分的锁,在整个同步周期内都是不存在竞争的"这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,
轻量级锁反而会比传统的重量级锁更慢。
5.偏向锁
偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,
进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互
斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
- 定义:偏向锁,会偏向于第一个获取它的线程,如果这个锁没有被其他线程获取,那么持有锁的线程永远不需要进行同步,一旦出现另一个线程尝试获取这个锁,偏向模式立即结束。
- 由图可见,偏向锁会在Mark Word里面存储偏向的线程ID,会占用HashCode的位置,所以,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。
- 偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。其实现在大部分编码情况下,偏向锁都很难成立,所以最新的jdk版本默认关闭了偏向锁。
四、锁升级过程
当一个锁,被一个线程第一次获取,那么这个锁就是一个偏向锁,如果这时候有第二个线程来竞争锁,那么锁就会升级为轻量级锁,在轻量级锁的时候,未持有锁的线程会自旋等待,如果自旋次数结束还没有获取到锁,就会升级为重量级锁。
五、其他锁和AQS
AQS是AbstractQueuedSynchronizer的缩写,它提供了一个FIFO队列,可以看成是一个实现同步锁的核心组件。AQS是一个抽象类,主要通过继承的方式来使用,它本身没有实现任何的同步接口,仅仅是定义了同步状态的获取和释放的方法来提供自定义的同步组件,AQS可以看做Lock接口下一整套结构的基石。
AQS的实现依赖内部的同步队列,也就是FIFO的双向队列,如果当前线程竞争失败,那么AQS会把当前线程以及等待状态信息构造成一个Node加入到同步队列中,同时再阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。
AQS队列内部维护的是一个FIFO的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的范文前驱和后继节点。每个Node其实是由线程封装,当线程争抢锁失败后会封装成Node加入到AQS队列中。
我们以最常见的ReentrantLock读写锁来简单看一下,ReentrantLock的tryLock,里面的实际实现是内部内的Sync,Sync继承AQS。
java
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
部分Sync源码
java
abstract static class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = -5179523762034025860L;
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
//获取AQS的stae变量
int c = getState();
if (c == 0) {
//CAS变动上锁
if (compareAndSetState(0, acquires)) {
//成功则上锁
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
ReentrantLock的tryLock默认的是非公平锁,用公平锁会极大降低锁的性能,慎用。
- 公平锁:线程获取锁的顺序先来先得。
- 非公平锁:线程获取锁跟顺序无关
其中关键就是getState()这个方法,AQS内部定义一个volatile 修饰的状态state,由它通过CAS变动的方式来控制线程的状态。
java
private volatile int state;
上面就是本文所有内容了,本人能力有限,有不对的或者需要补充的欢迎大家指出,也希望大家看完不要吝啬小指头,帮忙点个关注或者赞。