synchronized关键字

本文基于JDK1.8编写

关键点

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待。
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响。例外:锁对象是*.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁。
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁

应用方式

public synchronized void A(){
  ...
}

public void B(){
  synchronized(this){
    ...
  }
}

这里的A和B方法意义相同,都锁定的是this,也就是锁定当前对象实例。

class SyncTest {
  public synchronized static void A(){
    ...
  }

  public void B(){
    synchronized(SyncTest.class){
      ...
    }
  }
}

这里的A和B方法意义相同,都锁定的是SyncTest类,也就是说不管是在哪个实例中,只要是SyncTest类就用的是同一把锁

原理解析

原理概述

synchronized可重入锁。最初是将锁的工作交由操作系统解决的也就是我们说的重量级锁,JVM在JDK1.6后对其进行了优化,加入了轻量级锁偏向锁,引入了锁升级的概念,也加入了锁粗化锁消除这样的优化方式。

什么是可重入锁?

可重入:(来源于维基百科)若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。

可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。

重量级锁是怎么实现的?

monitorenter:用来获取锁,使锁计数器+1
monitorexit:用来释放锁,使锁计数器-1


需要注意

  • 每一个对象在同一时间只与一个monitor(锁)相关联
  • 一个monitor在同一时间只能被一个线程获得
  • 一个对象在尝试获得与这个对象相关联的monitor锁的所有权的时,会发生下列三种情况之一:
    • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
    • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
    • 这把锁已经被别的线程获取了,等待锁释放


示例

public class SyncTest {

    public static void main(String[] args) {
        SyncTest test=  new SyncTest();
        test.method1();
    }

    private synchronized void A() {
        System.out.println(Thread.currentThread().getId() + ": A()");
        B();
    }

    private synchronized void B() {
        System.out.println(Thread.currentThread().getId()+ ": B()");
        C();
    }

    private synchronized void C() {
        System.out.println(Thread.currentThread().getId()+ ": C()");
    }
}

结合前文中加锁和释放锁的原理,不难理解:

- 执行monitorenter获取锁 
  -(monitor计数器=0,可获取锁)
  - 执行A()方法,monitor计数器+1 -> 1 (获取到锁)
  - 执行B()方法,monitor计数器+1 -> 2
  - 执行C()方法,monitor计数器+1 -> 3
- 执行monitorexit命令 
  - C()方法执行完,monitor计数器-1 -> 2
  - B()方法执行完,monitor计数器-1 -> 1
  - A()方法执行完,monitor计数器-1 -> 0 (释放了锁)
  -(monitor计数器=0,锁被释放了)


JVM锁优化

锁升级

synchronized锁一共有四种类型,无锁,偏向锁,轻量级锁,重量级锁。偏向锁是默认关闭的,他们是逐步升级的,这个升级是不可逆的,也就是说,只会升级,不会降级。目的是为了在不同情况下选用最符合的锁类型,在计算资源消耗和执行效率之间取到最合适的平衡点。

对象与锁

看这部分以前建议先看下我这篇Java对象解析,这里讲解了对象头,里面Mark Word部分就是这部分锁和对象的实现。

为了标记当前Mark Word记录的内容,Mark Word中记录了用lockbiased_lock两列记录了当前Mark Word的含义。

biased_lock lock 状态
0 01 无锁
1 01 偏向锁
0 00 轻量级锁
0 10 重量级锁
0 11 GC标记

偏向锁

关闭延迟开启偏向锁:-XX:BiasedLockingStartupDelay=0(默认延迟4s)
禁止偏向锁:-XX:-UseBiasedLocking
偏向锁会将首个进入锁的线程的线程ID记录到Mark Word中的thread中,一旦产生锁竞争就会将锁升级为轻量级锁。


获取锁

  1. 访问Mark Word中偏向锁标志位biased_lock是否设置成1,锁标志位是否为01(确认为偏向锁)。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程。
    1. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word线程ID设置为当前线程ID。执行同步代码。
    2. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

释放锁
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。


偏向锁的撤销
需要等待全局安全点(safepoint),它会首先暂停拥有偏向锁的线程,然后判断这个线程:

  • 线程如果在同步代码块内,就将锁升级为轻量级锁
  • 线程如果不在同步代码块中,就直接将锁设置为无锁状态

轻量级锁

加锁和释放锁的逻辑

  1. 在轻量级锁上锁时,会在栈帧中创建一块名叫Lock Record的空间,而后将这些信息复制到当前线程的栈帧中,官方将这些信息称为Displaced Mark Word
  2. 用CAS的方式将对象的Mark Word信息更新为指向Lock Record的指针。
    1. 如果更新成功,那么这个线程就持有了该对象的锁,然后会把锁标志位lock更新为00,也就是轻量级锁。
    2. 如果更新失败,会校验是否已经指向了当前线程Lock Record,如果已经指向了,那就直接使用。如果没有指向则进入自旋等待,如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀位重量级锁,没有获得锁的线程会被阻塞。
  3. 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头中,如果成功,则表示没有发生竞争关系。如果失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

锁消除

锁消除是指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除

当然在实际开发中,我们很清楚的知道哪些是线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。

比如:在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化。在JDK 1.5之前会使用StringBuffer对象的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuidler对象的连续append()操作。
众所周知,StringBuilder不是安全同步的,但是JVM判断该段代码并不会逃逸,则默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。(还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)

锁粗化

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。

这里贴上根据上述Javap 编译的情况编写的实例java类

public static String test04(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

在上述的连续append()操作中就属于这类情况。JVM会检测到这样一连串的操作都是对同一个对象加锁,那么JVM会将加锁同步的范围扩展(粗化)到整个一系列操作的 外部,使整个一连串的append()操作只需要加锁一次就可以了。

锁的优缺点比较

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



文章引用

本文参考文档1本文参考文档2

我的其他相关文章

Java对象解析

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容