JVM-锁与并发
标签:JVM

锁与并发

1. 对象头和锁

在Java虚拟机每个对象都有一个对象头,用于保存对象的系统信息。对象头中有一个Mark Word的部分,它是实现锁的关键。32位系统中占32位,64位系统占64位。它是一个多功能数据区,可以存放对象的哈希值,对象年龄,锁的指针等信息。一个对象是否占有锁,以及占有哪些锁等。

32位系统中,普通对象的对象头:

偏向锁对象:

2. 锁在JVM中的实现和优化

2.1 偏向锁

偏向锁是JDK1.6提出的一种锁优化方式,其核心思想是:**如果程序没有竞争,则取消之前已经取得锁的线程的同步操作,即某一锁被线程获取后,便进入偏向模式,当线程再次请求这个锁时,无需再进行相关的同步操作,从而节省时间。**可以使用 -XX:+UseBiasedLocking可以设置启用偏向锁。

不启用偏向锁:

启用偏向锁:

上述代码使用一个线程对Vector进行写入操作,由于Vector内部是同步控制的,每次add操作都会请求list对象的锁。

-XX:BiasedLockingStartupDelay表示虚拟机启动后,立即使用偏向锁,如不设置该参数,虚拟机默认会在启动4秒后,才启用偏向锁。

在少竞争的情况下,偏向锁堆系统性能有一定帮助,但在激烈竞争的条件下,没有太强优化效果,因为大量的竞争会导致持有锁的线程不停的切换,锁也很难保持在偏向模式,还有可能降低系统系统性能,故在竞争激烈条件下,禁用偏向锁。

2.2 轻量级锁

如果偏向锁获取失败,虚拟机并不会让线程挂起,JVM会让线程申请轻量级锁,轻量级锁只是简单的将对象头作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁,如果线程获得轻量级锁成功

2.3 锁膨胀

当轻量级锁申请失败后,虚拟机就会使用重量级锁。线程在尝试进入锁的时候,线程可能会在操作系统层面被挂起,如果这样,线程间切换和调度成本将会很高。

2.4 自旋锁

由于锁膨胀后,可能会在操作系统层面挂起,所以希望线程可以尽快进入临界区而避免被操作系统挂起,采用自旋锁。

自旋锁可以使线程在没有获得锁的时候,不被挂起,而转去执行一个空循环(即所谓的自旋),在若干个空循环之后,线程如果可以获得锁,则继续执行,若线程依然不能获得锁,才会被挂起。使用自旋锁后,线程被挂起的几率变小了,线程执行的连贯性增加,对于那些竞争不是很激烈的锁,占用时间短的锁,具有一定积极意义,但是对于那些竞争激烈,自旋等待一段时间后仍旧无法获得锁的情况下,不仅会白白浪费CPU时间,最终还免不了被挂起的,反而浪费了系统资源。

2.5 锁消除

锁消除是Java虚拟机在JIT编译时,通过对上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间。

测试代码:

package com.liuyao;

/**
 * Created By liuyao on 2018/5/13 19:47.
 */
public class LockEliminate {
    private static final int CIRCLE=20000000;

    public static void main(String[] args) {
        long start=System.currentTimeMillis();
        for (int i = 0; i < CIRCLE; i++) {
            createStringBuffer("liuyao","ok");
        }
        long end=System.currentTimeMillis();
        System.out.println("cost: "+(end-start));
    }

    public static String createStringBuffer(String s1,String s2){
        StringBuffer stringBuffer=new StringBuffer();
        stringBuffer.append(s1);
        stringBuffer.append(s2);
        return stringBuffer.toString();
    }
}

关闭锁消除后执行(逃逸分析和锁消除必须工作在server模式下):

-server
-XX:+DoEscapeAnalysis
-XX:-EliminateLocks
-Xcomp
-XX:-BackgroundCompilation
-XX:BiasedLockingStartupDelay=0
-XX:+PrintCommandLineFlags

createStringBuffer函数中的stringBuffer变量作用域为整个方法内部,不可能逃逸出该方法,故不能被多个线程同时访问,关闭锁消除后,每次append()方法都会进行锁的申请,时间花费长

开启锁消除后执行:

-server
-XX:+DoEscapeAnalysis
-XX:+EliminateLocks
-Xcomp
-XX:-BackgroundCompilation
-XX:BiasedLockingStartupDelay=0
-XX:+PrintCommandLineFlags

通时关闭了偏向锁后:

-server
-XX:+DoEscapeAnalysis
-XX:-EliminateLocks
-Xcomp
-XX:-UseBiasedLocking
-XX:-BackgroundCompilation
-XX:BiasedLockingStartupDelay=0
-XX:+PrintCommandLineFlags

可见偏向锁简化了锁的获取,关闭以后,时间花费将会更大。

3. 锁在应用层的优化

3.1 减少锁持有时间

public synchronized void syncMethod(){
    method1();
    mutexMethod();
    method2();
}

如果上面的这个函数只需要mutexMethod()需要做同步控制,那么锁可以只加在这个函数外面:

public  void syncMethod(){
    method1();
    synchronized(this){
        mutexMethod();
    }
    method2();
}

减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发性能。

3.2 减少锁的粒度

如ConcurrentHashMap中的分段锁,只有当系统需要取得全局锁时,其消耗的资源才会增加。

减少锁的粒度,就是缩小锁定对象的范围,从而减少锁冲突的可能性,进而提高系统的并发能力。

3.3 锁分离

锁分离是减小锁粒度的一个特例,将一个锁分成读个锁,一个典型例子就是LinkedBlockingQueue。take操作和put操作在链表的两端进行,使用两把锁分离了读取数据和写入数据。

3.4 锁粗化

第一种:

public  void syncMethod(){
    synchronized(this){
        mutexMethod1();
    }

    synchronized(this){
        mutexMethod2();
    }
}

合并成:

public  void syncMethod(){
    synchronized(this){
        mutexMethod1();
        mutexMethod2();
    }
}

第二种:

public  void syncMethod(){
    for (int i = 0; i < 100; i++) {
        synchronized(this){
            mutexMethod1();
        }
    }
}

合并成:

public  void syncMethod(){
    synchronized(this){
        for (int i = 0; i < 100; i++) {
            mutexMethod1();
        }
    }
}

性能优化根据实际的运行情况对各个资源点权衡折中,锁粗化思想和减少锁的保持时间有时是相反的,在不同场合下,效果并不相同。

4. 无锁操作

4.1 CAS

CAS(compare and swap)比较和交换,与有锁相比,无锁算法的设计比较复杂,但是由于其非阻塞性,它对死锁天生免疫,并且线程间的影响也比远远比锁的方式要小。完全没有锁竞争的开销,也没有线程调度带来的开销。

4.2 原子操作

JDK的atomic包的原子操作类使用的CAS操作。在CAS算法中,首先是一个无穷循环,在这个无穷循环中用于多线程间的冲突处理,即在当前线程受到其他线程影响而更新失败时,会不停的尝试,直到成功。

4.3 LongAddr

将数据分离成多个单元cell,每个cell独自维护内部的值,当前对象的实际值由所有的cell累计合成。

  • 8 min read

CONTRIBUTORS


  • 8 min read