目录
一,概述
什么是java的锁?
为什么java要有锁?
java的锁为什么需要优化?
怎么优化的?
1,java中使用synchronized关键字来实现同步功能,被synchronized修饰的方法或是代码块,在多线程的情况下不能同时执行,只能挨个执行,以避免一些多线程并发的问题,这是java同步语句本身存在的意义。
2,在JDK1.5之前,事情就是像前面说的那么简单,当线程A和线程B同时执行到同步代码块时,他们会争抢同步锁,假设线程A抢到了锁,那么线程B就会从运行状态(Running)变成阻塞(Blocked)状态,直到线程A退出同步代码块,线程B才能获得锁,从阻塞状态变成运行状态,进入代码块开始执行。
3,到JDK1.6,java的开发者们不再满足于这种简单的运行-阻塞-运行模式了,因为这么在操作系统中切换线程的上下文的确挺慢,于是他们搞了一套优化的方案,也就是引入偏向锁,轻量级锁,自旋锁和重量级锁等概念,来提高同步锁的效率。从此,synchronized还是那个synchronized,用法还是那个用法,但是JVM不一定在拿不到锁的时候就直接阻塞线程了,而是有了一套更快一点的方案。
4,当锁的竞争很少或者基本没有时,JVM使用偏向锁来处理同步锁,这基本就算是没加锁。锁竞争越激烈的场景,JVM会把锁的处理方案会按照偏向锁,轻量级锁,自旋锁,重量级锁的顺序不断升级(或者叫锁的膨胀),这些锁的方案会消耗越来越多的资源,锁的效率也越来越低,所以JVM能用前面的方案就不会用后面的方案。
各个锁的具体介绍在后面。
先了解几个基本概念
二,CAS算法
CAS的全称是Compare-And-Swap,CAS算法的基本思想是这样的:当我们要改变一个变量的值时,先判断变量的值是否和某个预期值相同,如果相同则修改,如果不同则不修改。这个算法可以保证在多线程同时修改某个变量时,不会产生线程安全问题。
java中的java.util.concurrent.atomic包下的类很多都实现了这种算法,他们使用compareAndSet()方法来实现CAS,而且往往这个compareAndSet()方法对JVM来说都是原子操作,很安全。
三,Java对象的对象头,以及Mark Word
java的对象由三部分组成:对象头,实例数据,填充字节。
非数组对象的对象头由两部分组成:指向类的指针和Mark Word。
数组对象的对象头由三部分组成,比非数组对象多了块用于记录数组长度。
Mark Word用于记录对象的HashCode和锁信息等,在32位JVM中的Mark Word长度为32bit,在64位JVM中的Mark Word长度为64bit。
Mark Word的最后2bit是锁标志位,代表当前对象处于哪种锁状态,当Mark Word处于不同的锁状态时,Mark Word记录的信息也有所不同。
32位JVM中不同锁状态的Mark Word记录的信息如下表:
锁状态 | 25bit | 4bit | 1bit | 2bit | |
23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
无锁 | 对象的HashCode | 分代年龄 | 0 | 01 | |
偏向锁 | 线程ID | Epoch | 分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向重量级锁的指针 | 10 | |||
GC标记 | 空 | 11 |
可以看到无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。
随着锁的升级,Mark Word里面的数据就会按照上表不断变化,JVM也会按照Mark Word里面的信息来判断对象锁处于什么状态。
关于对象头和Mark Word的详细介绍见下面的连接:
https://blog.csdn.net/lkforce/article/details/81128115
下面单独介绍各个锁
四,偏向锁 Baised Lock
偏向二字就是字面上的意思,说的是第一个试图获取锁的线程,JVM会把锁对象的Mark Word从无锁状态变成偏向锁状态,并把线程id记在锁对象的Mark Word中,当这个线程以后还想要获取这个锁时,JVM发现这个锁对象处于偏向锁状态,而且线程id就是这个线程自己,就直接让他通过,不用再进行争抢锁的操作了,省了CAS操作的时间。
当然这个特权只有第一个获取锁的线程才能拥有,这也就是偏向二字的意思。
如果有第二个线程想要来争抢锁,JVM发现锁对象处于偏向锁状态,而且线程id是另外一个线程,新线程会使用CAS操作试图争抢对象锁,如果成功,Word Mark中的线程id就会替换为新线程的id,如果失败,这个偏向锁就会升级为轻量级锁,同样也是改锁对象的Mark Word。
由此可见,偏向锁是一种用缓存空间换时间的方案,在锁竞争不是很激烈的情况下会很有用,如果竞争比较激烈,JVM先使用偏向锁然后又不断进行锁升级,锁的效率会下降。
启用偏向锁的方式:
-XX:+UseBiasedLocking
-XX:BiasedLockingStartupDelay=0
关闭偏向锁的方式:
-XX:-UseBiasedLocking
五,轻量级锁
如果对象锁升级为轻量级锁,JVM会在当前线程的线程栈中开辟一块单独的空间叫锁记录(Lock Record),锁记录由两部分组成,分别是Displaced hdr和Owner。
JVM会把锁对象的Mark Word复制进去,然后把在对象Mark Word中保存指向锁记录的指针,并在锁记录的Owner中保存指向Mark Word的指针。这两个保存操作都是CAS操作。
如果保存成功,则表示当前线程获得该轻量级锁,修改锁对象的Mark Word锁标志位为00。
如果保存失败,JVM就检查锁对象的Mark Word是否已经保存了指向当前线程的指针,如果有则说明当前线程已经获得了这个锁,可以继续执行。如果没有指向当前线程的指针,则代表抢锁失败。
当前线程抢锁失败后会用自旋锁重试抢锁,如果一直失败,当前锁会升级为重量级锁,线程会被阻塞,锁对象的Mark Word标志位也会改为10。
六,自旋锁 SpinLock
spin在英文中用于描述纺纱的纱轮疯狂自转的样子,瞧这名字起的,一看就很耗CPU。
自旋锁其实并不属于锁的状态,从Mark Word的说明可以看到,并没有一个锁状态叫自旋锁。所谓自旋其实指的就是自己重试,当线程抢锁失败后,重试几次,要是抢到锁了就继续,要是抢不到就阻塞线程。说白了还是为了尽量不要阻塞线程。
由此可见,自旋锁是是比较消耗CPU的,因为要不断的循环重试,不会释放CPU资源。另外,加锁时间普遍较短的场景非常适合自旋锁,可以极大提高锁的效率。
在JDK1.6之前,自旋锁可以用参数来确定是否启用,以及自旋的次数:
-XX:+UseSpinning 启用自旋锁
-XX:PreBlockSpin=10 自旋次数10次
而从JDK1.7开始,自旋锁默认启用,而且JVM有了一套确认自旋次数和自旋周期的方案:
1,如果平均负载小于CPUs则一直自旋。
2,如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞。
3,如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞。
4,如果CPU处于节电模式则停止自旋。
5,自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据之间的时间差)。
6,自旋时会适当放弃线程优先级之间的差异。
java代码可以实现类似自旋锁的功能,网上有很多,我贴一个jetty中写的自旋锁的例子:
import java.util.concurrent.atomic.AtomicReference;
public class SpinLock
{
private final AtomicReference<Thread> _lock = new AtomicReference<>(null);
private final Lock _unlock = new Lock();
public Lock lock()
{
Thread thread = Thread.currentThread();
while(true)
{
if (!_lock.compareAndSet(null,thread))
{
if (_lock.get()==thread)
throw new IllegalStateException("SpinLock is not reentrant");
continue;
}
return _unlock;
}
}
public boolean isLocked()
{
return _lock.get()!=null;
}
public boolean isLockedThread()
{
return _lock.get()==Thread.currentThread();
}
public class Lock implements AutoCloseable
{
@Override
public void close()
{
_lock.set(null);
}
}
}
主要是思路就是用AtomicReference类的compareAndSet()方法,这个方法是原子操作,通过不断循环来重试获得锁。
七,重量级锁
重量级锁就是java最原始的同步锁,抢不到锁的线程就会被阻塞,在等待池中等待激活。
这种锁不是公平锁,来的早的线程不一定优先激活。
关于线程的等待池,JVM也有一套完整的运行方案。
八,在应用层提高锁效率的方案
上面所说的锁的优化方案都是在JVM内部的,由JVM自己搞定。在应用层,开发者也可以采取一些措施,提高锁的效率。
1,减少锁的持有时间
指的是不需要同步执行的代码,不要放在同步代码块中。同步块中代码减少,锁的持续时间短,锁的性能会有所提高。
2,减小锁粒度
指的是把资源分批使用不同的锁,不同批次的资源的操作互不影响。
比如ConturrentHashMap类,把map分成多段,每段一个锁,不在一段的数据可以同时修改。
3,锁分离
把关系不大的操作使用不同的锁,使这些操作互不影响。
比如LinkedBlockingQueue类,从队列头获取数据的take()方法和从队列末尾添加数据的put()方法分别使用不同的锁,两者互不影响。
4,锁粗化
指的是当虚拟机需要连续对同一把锁进行加锁和释放时,尽量改成只使用一次锁。
比如连续多个synchronized语句块,或循环中的synchronized语句块,用的是同一个对象作为锁,那还不如直接用一个synchronized语句块把他们都包含起来。
5,弃用synchronized关键字
不使用synchronized关键字,可以自己编写代码实现类似偏向锁、自旋锁的功能,减少因为同步锁而带来的效率损耗。比如上文中jetty自己实现的自旋锁。
以上。