《深入理解Java虚拟机》学习笔记(8)--线程安全与锁优化

Java中的线程安全
按照线程安全的“安全程度”由强至弱来排序,java里面各种操作共享的数据分为以下5类:不可变,绝对线程安全,相对线程安全,线程兼容,线程对立 
  • 不可变
不可变的对象一定是线程安全的。被final修饰的基本数据类型是不可变的。对象也可以是不可变的,前提是对象的行为不会对其状态产生任何影响(只要将对象的所有字段都用final修饰即可)String、Integer、Long、Double等都是不可变对象。
  • 绝对线程安全
一个类要想达到绝对线程安全,需要做到无论什么时候,都不需要额外的同步措施。这需要很大的代价。
  • 相对线程安全
这就是我们通常意义上的线程安全,它只需要保证对对象单独的一个操作是线程安全的。
  • 线程兼容
线程兼容是指对象本身并不是线程安全的,单可以通过在调用时正确使用同步手段来保证对象在并发环境中可以安全使用,我们平时说一个类不是线程安全的,通常是指这种情况。
  • 线程对立
线程对立指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。如Thread类的suspend和resume方法。
线程安全的实现方法
  • 互斥同步
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是几个,在使用信号量时)线程使用,而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。
互斥是因,同步是果;互斥是方法,同步是目的。
Java中最基本的互斥同步手段就是synchronized关键字,它对同一个线程来说是可重入,当一个线程在同步块中执行的时候,会阻塞后面其他线程的进入。另外还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,相比synchronized,ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁以及锁可以绑定多个条件:
等待可中断是指线程在尝试获取锁时可以指定一个等待时间,若锁被其他线程持有,则休眠等待,如果经过等待时间仍未获取到锁,则放弃等待。
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认也是非公平的,不过可以设置为公平的。
锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象。
  • 非阻塞同步
互斥同步是一种悲观的并发策略,即认为不做同步就会出错,访问共享数据时不论是否真的存在竞争都会加锁,最主要的问题是进行线程阻塞和唤醒时都要陷入内核,开销较大。
非阻塞同步是一种基于冲突检测的乐观并发策略,就是认为多个线程进行争用是很少发生的,因此可以先进行操作,如果没有和其他线程发生争用就成功了,如果有争用产生了冲突,那就再采取其他的补偿措施(最常见的就是不断重试直至成功)。
实现非阻塞同步需要硬件支持,通常就是通过CAS(Compare And Swap)原语来实现的。
  • 无同步方案
要保证线程安全,并不是一定就要进行同步,有一些代码天生就是线程安全的,比如可重入代码和线程本地存储的代码,因为这两者都不涉及在多个线程间共享数据。
锁优化
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析的数据支持,如果即时编译器判断一段代码中的同步块所保护的对象压根就不会被多个线程同时访问,则可以将相应的加锁和解锁操作删除,从而消除不必要的同步操作,提高程序的性能。
锁粗化
原则上总是推荐将同步块的作用范围限制得尽量小,同步块内的语句尽可能少,这可以尽快的释放锁,其他等待锁的线程也能尽快拿到锁。但是如果一系列的连续操作都是对同一个对象反复加锁和解锁,甚至是在循环体中不停的加锁解锁操作的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗,此时虚拟机就会把将锁同步的范围扩大(粗化)到整个操作序列的外部,以减少加锁和解锁操作的次数,从而提高性能。
自旋锁与自适应自旋
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。

如果同步块内的操作很少,线程可以很快地完成同步块内的操作,那么很可能挂起线程和恢复线程所花费的时间比执行同步块内代码的时间还要长。这种情况下,如果计算机是多核系统,即多个线程可以分别在多个cpu上同时执行,此时一个线程在获取锁时如果发现锁已被其他线程持有,与其休眠等待,不如忙等待一段时间,因为持有锁的线程可能很快就会释放锁。这就是自旋锁。

如果锁被占用的时间很短,自旋等待的效果非常好,因为省去了大量的挂起线程和恢复线程的动作。但如果锁被占用的时间很长,那么自旋等待则会带来额外的性能浪费,因为自旋之后还是要休眠等待。

在JDK1.6之前,自旋次数是固定的,默认值是10次,之后自旋次数则不是固定的了,而是自适应的了, 是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果自旋等待总是可以成功,那么虚拟机会认为下一次还可以成功,从而增加自旋等待的次数;相反,如果自旋等待总是失败,则虚拟机则会将自旋等待省略掉,以免额外的性能浪费。
偏向锁
32位虚拟机下,对象的MarkWord值如下:
可以看出,对象拥有4种锁状态。

在多线程编程时,我们在访问共享数据时加锁,但在很多情况下,共享数据只有一个线程在访问,则此时的同步操作实际上是可以优化掉的。在没有锁争用的情况下(即在只有一个线程执行)的情况下,jvm会使用偏向锁来进行加锁操作。

偏向锁,顾名思义,它会偏向于第一个访问锁的线程。偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。最初,锁对象的锁标志位为01,处于无锁状态。当线程第一次尝试加锁时,发现锁对象处于无锁状态,则通过CAS的方式更新锁对象的markword----即将锁对象的markword置为偏向锁状态、且将线程的id写入到markword中,若CAS操作成功,则加锁成功,进入到同步块中执行。线程本身不会主动释放偏向锁。当该线程后续再次要进入同步块(即再次加锁)时,发现锁对象处于偏向锁状态且markword中记录的线程id为本线程的id,此时他不需要再作任何加锁操作,直接进入同步块中执行。可以看出,在没有锁争用(即只有一个线程)的情况下,偏向锁没有加锁的开销,连CAS这步操作都省了,效率非常高。

偏向锁只适用于没有锁争用的场景,一旦发现有另一个线程在争用锁,则jvm就会撤销偏向锁。回到线程第一次尝试加锁的时刻,如果线程CAS更新锁对象的markword失败了,说明此时至少还有另外一个线程在争用该锁,并且那个线程获取到了该偏向锁,且该锁偏向那个线程,此时jvm会撤销偏向锁。另一种会撤销的情况是,当锁对象处于偏向锁状态时(即有一个线程已经持有了该锁),如果此时另一个线程也来尝试加锁,则它会发现该锁处于偏向锁,则jvm也会撤销偏向锁。偏向锁撤销后,锁恢复到未锁定或升级到轻量级锁状态。
轻量级锁
当多个线程对锁的竞争很轻,比如多个线程交替进入临界区, 或者说稍微等待一下(自旋),另一个线程就会释放锁,此时jvm会使用轻量级锁。轻量级锁避免了传统锁的挂起线程和唤醒线程的开销。

在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(称为Displaced Mark Word),然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下:
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧。如果指向,说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。在CAS失败的情况下,虚拟机会尝试多次CAS(即自旋),若自旋多次都失败,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为”10”,Mark Word中存储的就是指向重量级(互斥量)的指针。

当线程退出同步块时,需要解锁轻量级锁。解锁是 与加锁一样通过CAS操作进行的,如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

在锁竞争很轻微的情况下(比如多个线程交替进入临界区),轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

总结如下:
  • 偏向锁:只有一个线程进入临界区;
  • 轻量级锁:多个线程交替进入临界区
  • 重量级锁:多个线程同时进入临界区。
当线程获取锁时,首先尝试获取偏向锁,如果失败则尝试自旋获取轻量级锁,自旋超过一定次数后则获取重量级锁。线程获取锁的过程如下图所示:

你可能感兴趣的:(java)