Java并发synchronized

synchronized关键字是不能继承的,也就是说,基类的方法synchronized f(){}在继承类中并不自动是synchronized f(){},而是变成了f(){}。继承类需要你显式的指定它的某个方法为synchronized方法!

出现线程安全的主要来源于JMM的设计,主要集中在主内存和线程的工作内存而导致的内存可见性问题,以及指令重排序导致的问题。线程运行时拥有自己的栈空间,在自己的栈空间运行,如果多线程间没有共享的数据也就是说多线程间并没有协作完成一件事情,那么,多线程就不能发挥优势。

共享数据的线程安全问题怎样处理?很自然而然的想法就是每一个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,因为每个线程所操作的都是当前最新的版本数据。那么,在Java关键字synchronized就具有使每个线程依次排队操作共享变量的功能。

用法

位置 分类 被锁对象 伪代码
方法 实例方法 实例对象 public synchronized void method() {}
  静态方法 类对象 public static synchronized void method() {}
代码块 实例对象 实例对象 synchronized (this) {}
  类对象 类对象 synchronized (Demo.class) {}
  任意对象 任意对象 String str = “”; synchronized (str) {}

synchronized锁可重入

使用synchronized时,当一个线程得到一个对象锁后,再次请求此对象锁时是可以再次得到该对象的锁。因此,在一个synchronizes方法/块的内部调用本类的其他synchronized方法/块时,也是永远可以得到锁的。进一步,子类可以通过可重入锁调用父类的同步方法。synchronized通过获取自增,释放自减的方式实现重入,即每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

1
2
3
4
5
6
7
8
class Parent {
    public synchronized void method1() {}
}
class Child {
    public synchronized void method2() {
        this.method1(); //可重入父类的同步方法
    }
}

对象锁(monitor)机制

任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。synchronized定义到代码块时,编译器会将monitorenter指令插入到开始位置,将monitorexit指令插入到结束处和异常处。JVM保证每个monitorenter必须有对应的monitorexit与之配对。

线程执行到monitorenter指令时,将会尝试获取被锁对象所对应的monitor的所有权,即尝试获得对象的锁。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。如果没有获取到monitor的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态,放进同步队列,等待monitorexit通知,再依次出队列,重新尝试获取monitor。

实现原理为:释放锁的时候会将值刷新到主内存中,其他线程获取锁时会强制从主内存中获取最新的值。

CAS(Compare And Swap)

线程获取锁是一种悲观锁策略,而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,不会阻塞其他线程的操作。CAS可理解为:比较值是否相同,表示没有被其他线程修改,不存在线程冲突,可以进行赋值;如果比较值不同,表示该值已经被其他线程修改,出现冲突就重试当前操作直到没有冲突为止,当然也可以选择挂起线程。

CAS的实现需要硬件指令集的支撑,在JDK 5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。

Java 1.6以前,synchronized未进行优化,monitor锁可以认为直接对应底层操作系统中的互斥量(mutex),在存在线程竞争的情况下会出现线程阻塞造成线程切换 ,这是阻塞同步。

而HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了减少获得锁和释放锁所带来的性能消耗,提高性能,在Java 1.6优化synchronized实现,引入了偏向锁和轻量级锁。偏向锁和轻量级锁借助CAS并不是武断的间线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,属于非阻塞同步。

CAS存在以下问题:

  • ABA问题:一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上是经过了两次修改重新变成了A。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号。原来的变化路径A->B->A就变成了1A->2B->3A。在Java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。
  • 自旋时间过长:CAS操作失败,不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。
  • 只能保证一个共享变量的原子操作:如果对多个共享变量进行操作,CAS就不能保证其原子性。解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。

Java对象头

锁信息记录在Java对象头。JVM用2个Word(32/64bit为一个Word)存储对象头,第一个Word为Mark Word,存储对象的hashcode,分代年龄和锁信息;第二个Word为Class Metadata Address,存储对象类型数据的指针(用于反射机制?)。如果是数组类型,对象头多一个Word,Array length存储数组长度。

32位JVM的Mark Word存储的数据会随着锁标志位的变化而变化。

锁状态 23bit 2bit 4bit 1bit 2bit
无锁状态 对象的hashCode   对象分代年龄 0 01
偏向锁 线程ID Epoch 对象分代年龄 1 01
轻量级锁 指向栈中锁记录的指针       00
重量级锁 指向互斥量(重量级锁)的指针       10
GC标记         11

锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

适用于锁大部分时间被同一线程获取的情况,如果频繁出现竞争,则表示该对象不适合作为偏向锁。

锁获得:检查Mark Word线程ID是否设置与当前线程ID一致

  1. 如果一致,表示之前已经获得了该偏向锁,执行同步体
  2. 如果不一致,通过CAS设置为当前线程ID,当无锁状态时设置成功,获得锁,将Mark Word线程ID指向当前线程ID;当锁已经被别的线程占用时设置失败,表示出现锁竞争,引起锁撤销

锁撤销:采用等到竞争出现才释放锁的机制,即当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

  1. 等待全局安全点(在这个时间点上没有正在执行的字节码)
  2. 暂停拥有偏向锁的线程,检查持有偏向锁的线程是否存活,如果线程不处于活动状态,将Mark Word设置成无锁状态;如果线程处于活动状态,拥有偏向锁的栈会被执行,遍历对象的偏向锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁
  3. 唤醒暂停的线程

关闭偏向锁:偏向锁在Java 1.6和Java 1.7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。

轻量级锁

加锁

  1. 在当前线程的栈桢中创建用于存储锁记录的空间,将Mark Word复制到锁记录,官方称为Displaced Mark Word
  2. 尝试使用CAS将对象头中的Mark Word替换为指向栈中锁记录的指针(原理和偏向锁类似,只是偏向锁设置的是线程ID,轻量级锁设置的是各自线程栈空间的锁记录)。如果成功,当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁

解锁

使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生;如果失败,表示当前锁存在竞争,锁就会升级成重量级锁。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

使用-XX:-UseSpinning参数关闭自旋锁优化,-XX:PreBlockSpin参数修改默认的自旋次数。

各种锁优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 追求响应时间。同步块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行速度较长。

参考:

彻底理解synchronized

BiasedLocking模式下markOop中位域epoch的根本作用是什么: 怎么判断这些对象是否适合偏向锁呢?jvm采用以类为单位的做法,其内部为每个类维护一个偏向锁计数器,对其对象进行偏向锁的撤销操作进行计数。当这个值达到指定阈值的时候,jvm就认为这个类的偏向锁有问题,需要进行重偏向(rebias)。对所有属于这个类的对象进行重偏向的操作叫批量重偏向(bulk rebias),之前的做法是对heap进行遍历,后来引入epoch。当需要bulk rebias时,对这个类的epcho值加1,以后分配这个类的对象的时候mark字段里就是这个epoch值了,同时还要对当前已经获得偏向锁的对象的epoch值加1,这些锁数据记录在方法栈里。这样判断这个对象是否获得偏向锁的条件就是:mark字段后3位是101,thread字段跟当前线程相同,epoch字段跟所属类的epoch值相同。如果epoch值不一样,即使thread字段指向当前线程,也是无效的,相当于进行过了rebias,只是没有对对象的mark字段进行更新。如果这个类的revoke计数器继续增加到一个阈值,那个jvm就认为这个类不适合偏向锁了,就要进行bulk revoke。于是多了一个判断条件,要查看所属类的字段,看看是否允许对这个类使用偏向锁。