深入理解JVM学习笔记-线程安全与锁优化

线程安全

如果一个对象可以安全的被多个线程同时使用,那他就是线程安全的。当多线程访问一个对象时,如果不同考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者调用方进行任何其他操作,调用对象的行为都可以获取正确的结果,那对象就是线程安全的。线程安全是基于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全来看,程序是串行还是多线程执行都是都是线程安全的。
Java语言中的线程安全:限定于与多个线程之间存在共享数据访问这个前提,Java语言中操作共享的数据分为以下五类:不可变(final)、绝对线程安全(Vector)、相对线程安全(Vector、hashtable,方法中都有synchronized)、线程兼容(线程兼容指对象本身并不是线程安全的,平时说的类不是线程安全的,就是指这一种情况,如hashmap)、线程对立(无法是否采取措施,都无线多线程安全)。
线程和共享数据:
(1)jvm运行时数据区域中只有堆和方法区的数据是线程共享区域,所以线程安全主要关注的时这两块内存中的数据。
(2)在Java虚拟中,每个线程独享一块栈内存,其中包括局部变量、线程调用的每个方法的参数和返回值。其他线程无法读取到该栈内存块中的数据。栈中的数据仅限于基本类型和对象引用。所以,在JVM中,栈上是无法保存真实的对象的,只能保存对象的引用。真正的对象要保存在堆中。
(3)在JVM中,堆内存是所有线程共享的。堆中只包含对象,没有其他东西。所以,堆上也无法保存基本类型和对象引用。堆和栈分工明确。
(4)JVM中有两块内存区域可以被所有线程共享:堆,上面存放着所有对象 ;方法区,上面存放着静态变量。如果有多个线程想要同时访问同一个对象或者静态变量,就需要被管控,否则可能出现不可预期的结果。
(5)为了协调多个线程之间的共享数据访问,虚拟机给每个对象和类都分配了一个锁。这个锁就像一个特权,在同一时刻,只有一个线程可以“拥有”这个类或者对象。如果一个线程想要获得某个类或者对象的锁,需要询问虚拟机。
(6)类锁:其实通过对象锁实现的。因为当虚拟机加载一个类的时候,会为这个类实例化一个 java.lang.Class 对象,当你锁住一个类的时候,其实锁住的是其对应的Class 对象
造成线程安全问题原因:
条件一:一是存在共享数据
条件二:二是存在多条线程共同操作共享数据
解决方案:为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行。这种方式叫互斥同步;也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。
同步:在Java中,当有多个线程都必须要对同一个共享数据进行访问时,有一种协调方式叫做同步。Java语言提供了两种内置方式来使线程同步的访问数据:同步代码块和同步方法。

线程安全实现方法

1.互斥同步

同步是指在过个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用。java提供的最基本互斥同步手段就是synchronized。除synchronized外还使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,Lock相关具体内容请参考《Java并发编程的艺术》中Java中锁章节。
(1)synchronized关键字实现互斥同步:synchronized有三种使用形式,修饰同步方法(静态方法和实例方法)和修饰代码块。使用javap查看字节码,发现对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。对于同步代码块。JVM采用monitorenter、monitorexit两个指令来实现同步。synchronized使用场景如下图所示:

Synchronized使用场景.png

synchronized修饰同步方法原理:同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
synchronized修饰同步代码块原理:synchronized修饰同步代码块时,经过编译之后,会在同步代码块前后形成monitorenter和monitorexit两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象,如果java程序中synchronized明确指定了对象参数,那就是这个对象的reference,如果没有明确指定,那就根据synchronized修饰的实例方法和类方法,去取对应的对象实例或者Class对象作为锁对象。执行monitorenter指令时,需要尝试获取对象的锁,如果这个对象没有锁定,或者当前线程已经 拥有了那个对象的锁,把锁计数器加1,响应的在执行monitorexit指令时,会将锁计数器减1,当计数器为0时,锁就被释放,如果获取锁对象失败,那么当前线程就要阻塞等待,知道对象锁被另外一个线程释放位置。synchronized对象锁与对象的内存布局中(对象头、实例数据、对其填充)对象头有关系。
synchronized原理简单总结:同步方法通过ACC_SYNCHRONIZED关键字隐式的对方法进行加锁。当线程要执行的方法被标注上ACC_SYNCHRONIZED时,需要先获得锁才能执行该方法。同步代码块通过monitorenter和monitorexit执行来进行加锁。当线程执行到monitorenter的时候要先获得所锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。
(2)Lock实现互斥同步:锁是用来控制多线程访问共享资源的方式,一个锁能够防止多个线程同时访问共享资源,它提供与synchronized关键字类似的功能,只是在使用时需要显示的获取和释放锁,虽然它缺少了隐私获取锁的便利,但是却拥有了锁获取和释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。Lock相关具体内容请参考《Java并发编程的艺术》中Java中锁章节。

2.非阻塞同步

互斥同步最主要问题就是线程阻塞和唤醒所带来的性能问题,因此也被成为阻塞同步,无论是否出现竞争,它都要进行加锁。非阻塞同步是基于冲突检测的乐观并发策略,先进行操作,如果没有其它线程争用共享数据,那就操作成功了,如果共享数据有争用,产生了冲突,那就采用其它补偿措施,最常见的补偿措施就是不断重试,直到成功为止,互斥同步手段Synchronized属于悲观锁,非阻塞同步CAS属于乐观锁。java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。例如AtomicBoolean,AtomicInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子性操作,atomic操作的底层实现正是利用的CAS机制、原子类是通过 Unsafe 类中的 CAS 指令从硬件层面来实现线程安全的。
CAS机制:当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
CAS原理:CAS操作会调用Unsafe类里边的compareAndSwapInt()和compareAndSwapLong()等几个方法,虚拟机内部对这些方法做了特殊处理,即编译出来的结果对应一条平台相关的处理器CAS指令,即比较交换操作是一个院子操作。JVM中CAS操作利用了处理器提供的CMPXCHG指令实现。所以说乐观锁是基于硬件指令集的。

3.无同步方案

保证线程安全,并不是一定要进行同步,两者没有因果关系,同步只是保证数据争用时的正确手段,如果一个方法本来就不涉及共享数据,那他自然就无须任何同步措施保证正确性,因此会有一些代码天生就是线程安全的。
(1)可重入代码:只要输入相同,就能返回相同结果,那它就满足可重入性的要求,自然就是线程安全的。
(2)线程本地存储:如果一段代码所需的数据必须与其他代码共享,并且这些共享数据可以保证在同一线程中执行,我们就可以把共享数据的可见范围限制在同一个线程之内,这样无需同步也能保证线程之间不出现数据争用问题,java语言中可以通过java.lang.ThreadLocal类来实现线程本地存储功能,ThreadLocal是一个线程内存的数据存储类,通过它可以在执行线程中存储数据,数据存储之后,只有在指定线程中可以获取到存储数据,其他线程是无法获取到该数据。Android消息机制中的Looper作用域就是线程,并且不同线程有不同的looper,所以消息机制通过ThreadLocal就可以轻松实现Looper在线程中的存取。

锁优化

为了减少获得锁和释放锁带来的性能消耗,虚拟机开发团队花费了大量的精力去实现各种锁优化技术。锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
要理解轻量级锁以及偏向锁的原理和运作过程,需要从JVM对象的内存布局开始介绍,Java对象的内存布局分为三块区域:对象头、实例数据和对齐填充。synchronized使用的锁对象是存储在Java对象头里。如果对象是数组类型使用3个字宽存储对象向头,如果是非数据类型用2字宽存储对象头,在32位虚拟机中,1字宽等于4字节,即32bit。Java对象头结构如下图所示:

Java对象头结构.JPG

MarkWord是实现偏向锁和轻量级锁的关键.其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等,以下是32位JVM的Mark Word默认存储结构如下图所示:
MarkWord默认存储结构.JPG

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间在运行期间,MarkWork里存储的数据会随着锁标志位的变化而变化,Mark Word可能变化为存储以下四种数据,如下图所示:
Mark Word状态变化.JPG

1.偏向锁

经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构(对象头里存储锁偏向的线程ID),当这个线程再次请求锁时,只需要简单测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,测试成功表示线程已获得锁,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
偏向锁撤销:只有在出现竞争的时候才会释放锁,所以当其他线程尝试竞争获取偏向锁时,持有偏向锁的线程才会释放。
偏向锁关闭:偏向锁在Java6和Java7默认是启动的,如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁。

2.轻量级锁

轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,这事一个经验数据,如果没有竞争轻量级锁使用CAS(无锁概念,也叫乐观锁)操作避免了使用互斥量的开销,如果存在锁竞争,处理互斥量的开销外,还会额外发生CAS操作,因此在竞争情况下,轻量级锁会比传统重量级锁更慢。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。轻量级锁加锁:线程在执行同步快之前,JVM会先在当前线程的栈桢中创建用户存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,如果成功,当前线程获得锁,如果失败表示其他线程竞争锁,当前线程尝试使用自旋来获取锁。轻量级锁解锁:轻量级锁解锁时,会使用原子操作CAS将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生,如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
偏向锁、轻量级锁、重量级锁优缺点以及适用场景如下图所示:


不同锁优缺点以及适用场景.JPG
3.自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

4.锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下代码所示:

    public String concatString(String str1,String str2,String str3){
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1);
        stringBuffer.append(str2);
        stringBuffer.append(str3);
        return stringBuffer.toString();
    }

    StringBuffer.java
    @Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

StringBuffer.append()方法中都有一个同步块,锁就是stringBuffer对象,虚拟机观察变量stringBuffer,发现其作用于被限制在concatString()方法内部,stringBuffer所有引用用于不会逃逸到concatString()方法之外,其它线程无法访问到它,因为虽然这里有锁,但是可以被安全的消除掉。在即时编译之后,这段代码就会忽略掉所有的同步而直接执行。

5.锁粗化

在写代码的时候推荐将同步快作用范围限制到尽量小,只在共享数据的实际作用域才进行同步,但是一系列操作都对同一个对象反复加锁和解锁,即时没有线程竞争,频繁的进行互斥同步也会导致不必要的性能损耗。例如在锁消除的示例代码中,就属于这类情况,如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把加锁的同步范围扩展到整个操作序列外部,锁消除的示例代码中就是扩展到第一append()操作之前直到最后一个append()操作之后,这样就只需要加一次锁就可以了。

参考资料

《深入理解JVM》
《Java并发编程的艺术》

推荐阅读更多精彩内容