Java高并发(一)- 并发编程的几个基本概念
Java高并发(二) - Java 内存模型与线程
Java高并发(三) - CountDownLatch、CyclicBarrier和Semaphore
Java高并发(四) - Java 原子类详解
Java高并发(五) - 线程安全策略
Java高并发(六) - 锁的优化及 JVM 对锁优化所做的努力
在高并发环境下,激烈的锁竞争会导致程序的性能下降,所以我们有必要讨论一下有关 锁 的性能问题及注意事项。如:避免死锁,减小锁粒度,锁分离等。
一、锁优化
1.1 减小锁持有时间
在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接的关系,如果线程持有锁的时间很长,那么相对地,锁的竞争程序也就越激烈。
示例代码:
public void syncMethod(){
fun1();
mutextMethod();
fun2();
}
在 syncMethod 方法中,假设只有 mutextMethod() 方法需要同步,而 fun1() 和 fun2() 不需要同步。如果 fun1() 和 fun2() 分别是重量级的方法,则会花费较大的 CPU 时间。此时如果在并发量较大,使用这种对整个方法做同步的方案,会导致等待线程大量增加。
一个较为优化的方案是,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统吞吐量。
public void syncMethod(){
fun1();
synchronized(this){
mutextMethod();
}
fun2();
}
在改进的代码中,只针对 mutextMethod 方法做同步,锁占用时间相对较短,因此能提高并行度。这种技术手段在 JDK 的源码总也可以嗯容易找到,如处理正则表达式的 Pattern 类:
public Matcher matcher(CharSequence input) {
if(!compiled){
synchronized(this){
if(!compiled){
compile();
}
}
}
Matcher m = new Matcher(this, input);
return m;
}
matcher() 方法有添加的进行锁申请,只有在表达式未编译时,进行局部加锁。这种处理大大提高了 matcher() 方法的执行效率和可靠性。
注意:减小锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。
1.2 减小锁力度
减小锁力度也是一种削弱多线程锁竞争的有效手段,这种技术的使用场景就是 ConcurrentHashMap 类的实现。
ConcurrentHashMap 不是直接锁住整个 HashMap,而是在 ConcurrentHashMap 内部进一步细分了若干个小的 HashMap,称之为段(Segment)。在 ConcurrentHashMap 中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时 (几乎) 不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。特别地,在理想状态下,ConcurrentHashMap 可以支持 16 个线程执行并发写操作(如果并发级别设为16),及任意数量线程的读操作。
Map 综述(三):彻头彻尾理解 ConcurrentHashMap
注意:所谓减小锁粒度,就是缩小锁对象的范围,从而减少锁冲突的可能性,进而提高系统的并发能力。
1.3 读写分离锁类代替独占锁
读写锁 ReentrantReadWriteLock 可以提高系统性能,使用读写分离锁来替代独占锁是减小锁粒度的一种特殊情况。读操作本身不会影响数据的完整性和一致性,因此,大部分情况下,可以允许多线程同时读,读写锁正是实现了这种功能。
注意:在读多写少的场合,使用读写锁可以有效提升系统并发能力。
1.4 锁分离
将读写锁思想进一步延伸,就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离。根据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离。
典型案例就是 LinkedBlockingQueue 的实现,take() 和 put() 分别实现了从队列中取得数据和往队列中增加数据的功能。虽两个数据都对当前队列进行了修改操作,但由于 LinkedBlockingQueue 是基于链表的,因此两个操作分别作用于队列的前端和尾端。
如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么 take() 和 put() 操作不可能真正的并发,在运行时,它们会彼此等待对方释放资源。这种情况下,锁竞争会相对比较激烈,从而影响呈现的高并发时的性能。
在 JDK 实现中,采用的是分离锁思想,使用了两把不同的锁,分离 take() 和 put() 操作。
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
上面的代码定义了 takeLock 和 putLock 分别在 take() 和 put() 操作中使用,因此 take() 和 put() 相对独立,它们之间不存在锁竞争关系,只需要在 take() 和 take() 之间,put() 和 put() 之间分别对 takeLock 和 putLock 进行竞争。
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly(); //不能有两个线程同时去数据
try {
while (count.get() == 0) { //如果当前没有可用数据,则一直等到
notEmpty.await(); //等待,put() 操作的通知
}
x = dequeue(); //取得第一个数据
c = count.getAndDecrement(); //数量减 1,原子操作,因为会和 put() 函数同时访问 count
if (c > 1)
notEmpty.signal(); //通知其他 take() 操作
} finally {
takeLock.unlock(); //释放锁
}
if (c == capacity)
signalNotFull(); //通知put()操作,已有空余空间
return x;
}
put()
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly(); //不能有两个线程同时去数据
try {
while (count.get() == capacity) { //如果队列已满,等待
notFull.await();
}
enqueue(node); //插入数据
c = count.getAndIncrement(); //更新总数,count 加 1
if (c + 1 < capacity)
notFull.signal(); //有足够的空间,通知其他线程
} finally {
putLock.unlock(); //释放锁
}
if (c == 0)
signalNotEmpty(); //插入成功后,通知 take()操作取数据
}
1.5 锁粗化
为保证多线程间有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应立即释放资源。但是,如果对同一个锁不停地请求,同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能优化。
为此,JVM 在遇到一连串连续地对同一锁不断请求和释放的操作时,便会把所有的锁操作整合成对锁的一次性请求,从而减少对锁的请求同步次数,这个操作叫做锁的粗化。
尤其在循环内请求锁的例子,这种情况下,意味着每次循环都有申请锁和释放锁的操作。
for(int i=0; i<CIRCLE; i++){
synchronized(lock){
//do something
}
}
改进,在循环外层只请求一次锁
synchronized(lock){
for(int i=0; i<CIRCLE; i++){
//do something
}
}
注意:性能优化就是根据运行时的真实情况对各个资源进行权衡折中的过程。锁粗化的思想和减少锁持有时间是相反的,但在不同场合,他们效果并不相同,所以大家需要根据实际情况进行权衡。
二、JVM 对锁优化锁做的努力
在 JDK1.6 之后,出现了各种锁优化技术,如轻量级锁、偏向锁、适应性自旋、锁粗化、锁消除等,这些技术都是为了在线程间更高效的解决竞争问题,从而提升程序的执行效率。
通过引入轻量级锁和偏向锁来减少重量级锁的使用。锁的状态总共分四种:无锁状态、偏向锁、轻量级锁和重量级锁。锁随着竞争情况可以升级,但锁升级后不能降级,意味着不能从轻量级锁状态降级为偏向锁状态,也不能从重量级锁状态降级为轻量级锁状态。
无锁状态 → 偏向锁状态 → 轻量级锁 → 重量级锁
对象头
要理解轻量级锁和偏向锁的运行机制,还要从了解对象头(Object Header)开始。对象头分为两部分:
1、Mark Word:存储对象自身的运行时数据,如:Hash Code,GC 分代年龄、锁信息。这部分数据在32位和64位的 JVM 中分别为 32bit 和 64bit。考虑空间效率,Mark Word 被设计为非固定的数据结构,以便在极小的空间内存储尽量多的信息,32bit的 Mark Word 如下图所示:
2、存储指向方法区对象类型数据的指针,如果是数组对象的话,额外会存储数组的长度
2.1 偏向锁
核心思想:当线程请求到锁对象后,将锁对象的状态标志位改为 01,即偏向模式。然后使用 CAS 操作将线程的 ID 记录在锁对象的 Mark Word 中。以后该线程可以直接进入同步块,连CAS操作都不需要。但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。
与轻量级锁的区别:轻量级锁是在无竞争的情况下使用CAS操作来代替互斥量的使用,从而实现同步;而偏向锁是在无竞争的情况下完全取消同步。
作用:偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序性能。
优点:偏向锁可以提高有同步但没有竞争的程序性能。但是如果锁对象时常被多条线程竞争,那偏向锁就是多余的。
2.2 轻量级锁
如果偏向锁失败,JVM 并不会立即挂起线程。他还会使用一种称为轻量级锁的优化手段。
核心思想:轻量级锁将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区,如果获取轻量级锁失败,则表示其它线程先抢到了锁,那么线程的锁请求就会膨胀为重量级锁。
前提:轻量级锁比重量级锁性能更高的前提是,在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争。若在该过程中一旦有其他线程竞争,那么就会膨胀成重量级锁,从而除了使用互斥量以外,还额外发生了CAS操作,因此更慢!
轻量级锁与重量级锁的比较:
- 重量级锁是一种悲观锁,它认为总是有多条线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用互斥同步来保证线程的安全;
- 而轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是使用CAS操作来获得锁,这样能减少互斥同步所使用的『互斥量』带来的性能开销。
2.3 自旋锁
锁膨胀后,JVM 为避免线程真正在操作系统层面挂起,JVM 做了最后的努力 — 自旋锁。由于当前线程无法获取锁,但什么时候获取锁是一个未知数。
因此,系统会进行一次赌注:它会假设在不久的将来,线程可以得到这把锁,JVM 让当前线程做几个空循环(这是自旋的含义)。在经过若干次循环后,如果可以得到锁,那么久顺利进入临界区。如果还不能获得锁,才会真正将线程在系统层面挂起。
优点:由于自旋等待锁的过程线程并不会引起上下文切换,因此比较高效;
缺点:自旋等待过程线程一直占用 CPU 执行权但不处理任何任务,因此若该过程过长,那就会造成 CPU 资源的浪费。
自适应自旋:自适应自旋可以根据以往自旋等待时间的经验,计算出一个较为合理的本次自旋等待时间。
2.4 锁消除
锁消除是一种更彻底的锁优化,JVM 在 JIT 编译时,通过对运行上下文的扫描,去除不可能存在的共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁的时间。
读者可能产生疑问,如果不可能产生竞争,为什么还要加锁?
在 Java 开发中,我们必然会使用 JDK 内置的 API,如 StringBuffer、Vector 等。你在使用这些类时,也许根本不会考虑这些对象到底内部是如何实现的。但是 Vector 内部使用了 synchronized 请求锁。