如何造好synchronized这艘火箭

简介: 本文主要为了讲明白在Java面试中,如何造好synchronized这艘火箭。

一、从线程安全开始

1.1、诱因

  • 存在共享数据(也称临界资源)
  • 存在多条线程共同操作这些共享数据 解决的根本办法其实很简单,只要保证同一时刻有且只有一个线程能操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行处理。

1.2、锁的内存语义

线程释放锁,JMM会把该线程中对应的本地内存中的共享变量刷新到主内存中。 线程获取锁,JMM会把线程对应的本地内存置为无效,从而使被监视器保护的临界区代码必须从主内存中读取共享变量。

1.3、互斥锁

  • 互斥性:同一时刻只允许一个线程持有某个对象锁,互斥性也成为原子性
  • 可见性:确保锁释放之前,对共享数据的修改,对后续获得该锁的线程可见Java中synchronized即为互斥锁,它锁的不是代码,锁的都是对象。

1.4、获取对象锁的方式:

1、同步代码块

// 代码示例
/**
* 方法中有 synchronized(this|object) {} 同步代码块
*/

privatevoidsyncObjectBlock1() {
   System.out.println(Thread.currentThread().getName() + "_SyncObjectBlock1: " +
           new SimpleDateFormat("HH:mm:ss").format(new Date()));
   synchronized (this) {
       // 观察一下是否是同一个示例对象
       System.out.println(Thread.currentThread().getName() + "_SyncObjectBlock1: " + this);
       try {
           System.out.println(Thread.currentThread().getName() + "_SyncObjectBlock1_Start: " +
                   new SimpleDateFormat("HH:mm:ss").format(new Date()));
           Thread.sleep(1000);
           System.out.println(Thread.currentThread().getName() + "_SyncObjectBlock1_End: " +
                   new SimpleDateFormat("HH:mm:ss").format(new Date()));
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
   }
}

2、同步非静态方法

/**
* synchronized 修饰非静态方法
*/

privatesynchronizedvoidsyncObjectMethod1() {
   System.out.println(Thread.currentThread().getName() + "_SyncObjectMethod1: " +
           new SimpleDateFormat("HH:mm:ss").format(new Date()));
   try {
       // 观察一下是否是同一个示例对象
       System.out.println(Thread.currentThread().getName() + "syncObjectMethod1: " + this);
       System.out.println(Thread.currentThread().getName() + "_SyncObjectMethod1_Start: " +
               new SimpleDateFormat("HH:mm:ss").format(new Date()));
       Thread.sleep(1000);
       System.out.println(Thread.currentThread().getName() + "_SyncObjectMethod1_End: " +
               new SimpleDateFormat("HH:mm:ss").format(new Date()));
   } catch (InterruptedException e) {
       e.printStackTrace();
   }
}

注意:同步块和同步非静态方法锁的是同一个对象,即this;同一个类的不同对象锁是互不干扰的。

1.5、获取类锁的方式:

1、同步代码块

// 代码示例
privatevoidsyncClassBlock1() {
   System.out.println(Thread.currentThread().getName() + "_SyncClassBlock1: " +
           new SimpleDateFormat("HH:mm:ss").format(new Date()));
   synchronized (SyncThread.class) {
       try {
           System.out.println(Thread.currentThread().getName() + "_SyncClassBlock1_Start: " +
                   new SimpleDateFormat("HH:mm:ss").format(new Date()));
           Thread.sleep(1000);
           System.out.println(Thread.currentThread().getName() + "_SyncClassBlock1_End: " +
                   new SimpleDateFormat("HH:mm:ss").format(new Date()));
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
   }
}

2、同步静态方法

privatesynchronizedstaticvoidsyncClassMethod1() {
   System.out.println(Thread.currentThread().getName() + "_SyncClassMethod1: " +
           new SimpleDateFormat("HH:mm:ss").format(new Date()));
   try {
       System.out.println(Thread.currentThread().getName() + "_SyncClassMethod1_Start: " +
               new SimpleDateFormat("HH:mm:ss").format(new Date()));
       Thread.sleep(1000);
       System.out.println(Thread.currentThread().getName() + "_SyncClassMethod1_End: " +
               new SimpleDateFormat("HH:mm:ss").format(new Date()));
   } catch (InterruptedException e) {
       e.printStackTrace();
   }
}

注意:对象锁和类锁是不会相互干扰的。

二、如何实现synchronized

在前文简单的了解synchronized的使用,这在面试中显然是不够的。 本章我们来讲一下它的底层实现原理,主要围绕Java对象头和Monitor,以JDK8&&hotspot JVM为叙述基础。

2.1、Monitor

Java对象在内存中的布局分为对象头、实例数据、对齐填充。 锁对象是存储在对象头中,对象头的结构为,

对象头结构 说明
Mark Word 存储对象hashcode、分代年龄、锁类型、锁标志位等信息
Class Metadata Address 对象元数据指针地址,JVM通过该指针获取对象的class信息

synchronized为重量级锁,该信息就被记录在对象的Mark Word中;Moinitor是Java对象天生自带的一把锁。每一个对象都有一个Moinitor对象与之关联,在hotspot中它由ObjectMonitor实现的,来看看它里面定义了啥?

// hotspot源码截取
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
 _header       = NULL;
 _count        = 0;
 _waiters      = 0,
 _recursions   = 0;
 _object       = NULL;
 _owner        = NULL;
 _WaitSet      = NULL;
 _WaitSetLock  = 0 ;
 _Responsible  = NULL ;
 _succ         = NULL ;
 _cxq          = NULL ;
 FreeNext      = NULL ;
 _EntryList    = NULL ;
 _SpinFreq     = 0 ;
 _SpinClock    = 0 ;
 OwnerIsThread = 0 ;
 _previous_owner_tid = 0;
}

重点关注以下field,

  • _owner:指向持有ObjectMonitor对象的线程
  • _WaitSet:存放处于wait状态的线程队列,即调用wait()方法的线程
  • _EntryList:存放处于等待锁block状态的线程队列
  • _count:约为_WaitSet 和 _EntryList 的节点数之和
  • _cxq: 多个线程争抢锁,会先存入这个单向链表
  • _recursions: 记录重入次数 定义中的WaitSet与EntryList,与线程的等待池和锁池可以联系起来,每个对象锁的线程都会封装至ObjectWait对象,并存储在里面;owner指向持有ObjectMonitor对象的线程,当多个线程同时访问同一段代码时候,首先会进入EntryList中,排队等候;当线程调用wait方法,那么ObjectWait对象会重新存入WaitSet,等待被唤醒。 Monitor同样存在于Java对象的对象头中,synchronized就是通过该方式获取锁,这也解释了Java中为什么任意对象都可以作为锁。

2.2、字节码分析

接下来分析一下synchronized具体在字节码层面的实现,

publicvoidsyncsTask() {
   // 同步代码块
   synchronized (this) {
       System.out.println("Hello");
   }
}

// 同步方法
publicsynchronizedvoidsyncTask() {
   System.out.println("Hello Again");
}

javap -v打开上述class编译之后的class文件,让我们聚焦到code区域, 首先分析syncsTask方法,

// syncsTask方法字节码

public void syncsTask();

 descriptor: ()V

 flags: ACC_PUBLIC

 Code:

   stack=2, locals=3, args_size=1

      0: aload_0

      1: dup

      2: astore_1

      3: monitorenter

      4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;

      7: ldc           #3                  // String Hello

      9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V

     12: aload_1

     13: monitorexit

     14: goto          22

     17: astore_2

     18: aload_1

     19: monitorexit

     20: aload_2

     21: athrow

     22: return

显而易见,同步代码块使用的是monitorenter与monitorexit指令,monitorenter指向同步代码块开始的位置,monitorexit则指明同步代码块结束的位置,两两配对执行。 当执行monitorenter指令时,当前线程将试图获取objectref(即对象锁) 所对应的monitor的持有权,当objectref的monitor的进入计数器为0,那线程可以成功取得monitor,并将计数器值设置为1,取锁成功。如果当前线程已经拥有objectref的monitor的持有权,那它可以重入这个monitor。 又一个新概念被引入了重入,下一节介绍(挖坑)。 字节码的19行多了一个monitorexit,前面不是说配对执行吗?这怎么多了一个monitorexit指令?其实这是编译器干了一些“坏事”,为了保证方法在异常时也能够正确的配对执行,编译器自动产生了一个异常处理器,可处理所有的异常并执行monitorexit指令,释放monitor。 再来看看syncTask(),

// syncTask方法字节码

public synchronized void syncTask();

 descriptor: ()V

 flags: ACC_PUBLIC, ACC_SYNCHRONIZED

 Code:

   stack=2, locals=1, args_size=1

      0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;

      3: ldc           #5                  // String Hello Again

      5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V

      8: return

这里我们未看到任何的monitor相关的指令,其实方法级的同步是隐式的无需通过指令来实现,出现在flags中的ACC_SYNCHRONIZED标志,即可用来区分方法是否同步。方法在运行时会判断标志位,执行线程也会取到monitor。

2.3、可重入

重入其实一句话解释就是当一个线程再次请求自己持有对象锁的共享数据时,这种情况属于重入。 synchronized是可重入锁;ReentrantLock也是。 即同一个线程可以输出Hello World不会死锁。

// 可重入
publicvoidsyncsTask() {
   synchronized (this) {
       System.out.println("Hello");
       synchronized (this){
           System.out.println("World");
       }
   }
}

三、为何嗤之以鼻

  • 早期JDK的synchronized是重量级锁,依赖于系统的Mutex Lock(互斥)
  • 线程之间切换从用户态转换至核心态,开销大。 hotspot做了很多的优化,JDK6之后synchronized的性能已经提升。 例如,自适应自旋、锁消除、锁粗化、轻量锁、偏向锁等等,使得线程之间更高效的共享数据,解决竞争问题,提高程序执行效率。

3.1、自旋锁与自适应自旋锁

许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得。 通过让线程处于忙循环等待锁释放,期间不出让CPU,减少线程的切换,该锁在JDK4就被引入。JDK6之后默认开启,处于自旋便会不再挂起线程,但如果锁占用时间过长,就不再推荐使用了,这时候应该通过参数PreBlockSpin参数来更改。 自适应自旋锁,自旋的次数不再固定,由前一次在同一个锁上的自旋时间与锁的拥有者状态来决定。

3.2、锁消除

更彻底的优化,JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。

publicvoidadd(String str1, String str2) {
   // StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
   // 因此sb属于不可能共享的资源,JVM会自动消除内部的锁
   StringBuffer sb = new StringBuffer();
   sb.append(str1).append(str2);
}

3.3、锁粗化

JVM对锁的范围进行扩大,减少锁同步的代价。

publicstatic String copyString100Times(String target){
   int i = 0;
   StringBuffer sb = new StringBuffer();
   while (i<100){
       sb.append(target);
   }
   return sb.toString();
}

3.4、synchronized的四个演变阶段

锁膨胀的方向:无锁、偏向锁、轻量级锁、重量级锁

偏向锁

减少同一线程获取锁的代价,大多数情况锁不存在竞争,总是由一个线程获取。 指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价,不适用于锁竞争比较激烈的多线程场合。

轻量级锁(flag可以单独开一章讲讲)

偏向锁升级而来,适用于线程交替执行同步块,自旋

重量级锁

同步块或者方法执行时间较长,追求吞吐量,详见前面小节分析。

优缺点

优点 缺点 场景
偏向锁 加锁和解锁无需CAS操作,没有额外的性能消耗,和无锁方法执行时间仅存纳秒差异 如果线程间存在锁竞争,会带来额外锁撤销的消耗 只有一个线程访问同步代码
轻量级锁 竞争的线程不会阻塞,响应速度提升 若线程长时间无法获取锁,自旋会消耗CPU 线程交替执行的同步代码
重量级锁 线程竞争不自旋,不消耗CPU 线程阻塞,响应时间缓慢,多线程下,频繁获取释放,性能消耗多 追求吞吐,同步代码执行时间长

四、写在最后

对于Java线程这块的内容文章几乎没有涉及,按照面试中的路子,其实一般是会从线程的基础切入到多线程与并发。这里再立一个flag,有关线程基础后续会开一个blog。 目前缺少源码的调用流程可视化呈现,后续涉及到本文中阐述的流程会使用图形式。 本文为Java面试造火箭之多线程与并发系列一,后续还会涉及JUC与线程池相关内容。 期待大家的关注,我们一起前行,定能造成这火箭🚀。


另外,如果对并发编程或者面试,想要了解更多请持续关注微信公众号:Java面试教程,关注更多有用的面试要点与技巧。

了解更多Java相关资料,请关注微信公众号:Java面试教程

回复: bbb20,获取更多Java资料与面试手册

回复: bbb19,获取Intellij idea最新版激活教程

让我们一起,玩转Java面试

相关文章
|
6月前
|
存储 缓存 安全
打工人,从 JMM 透析 volatile 与 synchronized 原理
打工人,从 JMM 透析 volatile 与 synchronized 原理
75 2
|
6月前
|
存储 安全 Java
synchronized 王的后宫总管,线程是王妃
synchronized 王的后宫总管,线程是王妃
32 0
|
3月前
|
Java 开发者 C++
Java多线程同步大揭秘:synchronized与Lock的终极对决!
Java多线程同步大揭秘:synchronized与Lock的终极对决!
78 5
|
3月前
|
监控 安全 IDE
别再瞎用了!synchronized的正确使用姿势在这里!
别再瞎用了!synchronized的正确使用姿势在这里!
87 4
|
3月前
|
安全 Java 开发者
Java多线程同步:synchronized与Lock的“爱恨情仇”!
Java多线程同步:synchronized与Lock的“爱恨情仇”!
86 5
|
3月前
|
安全 Java 开发者
【锁的艺术】StampedLock:Java并发编程的新武器!
【8月更文挑战第24天】`StampedLock`, 作为 Java 8 引入的新特性,为开发者提供了一种相较于传统 `ReentrantReadWriteLock` 更高效且灵活的锁机制。它属于 `java.util.concurrent.locks` 包,主要特点包括乐观读锁,这在多读少写的场景下能显著提升性能。
38 1
|
5月前
|
安全 Java 程序员
惊呆了!Java多线程里的“synchronized”竟然这么神奇!
【6月更文挑战第20天】Java的`synchronized`关键字是解决线程安全的关键,它确保同一时间只有一个线程访问同步代码。在案例中,`Counter`类的`increment`方法如果不加同步,可能会导致竞态条件。通过使用`synchronized`方法或语句块,可以防止这种情况,确保线程安全。虽然同步会带来性能影响,但它是构建并发应用的重要工具,平衡同步与性能是使用时需考虑的。了解并恰当使用`synchronized`,能有效应对多线程挑战。
20 1
|
6月前
|
Java 编译器 程序员
Volatile:Java并发编程的隐形英雄
Volatile:Java并发编程的隐形英雄
40 0
|
安全 Java 数据库连接
ThreadLocal Java多线程下的影分身之术
后来,我把c删掉,变成了下面这样。如果我现在想查h,按照上面getEntry的逻辑,是不是遍历到3就停了,所以找不到h了? getEntry的逻辑表面确实是这样,但实际上getEntryAfterMiss、remove、gets时都会直接或者间接调用expungeStaleEntry会对表里的数据做整理。expungeStaleEntry()除了利用弱引用的特性对tab中Entry做清理外,还会对之前Hash冲突导致后移的Entry重新安放位置。所以不可能出现下面这种tab排放的。
34 0
|
存储 数据可视化 安全
造火箭 | 详解synchronized的前世今生
造火箭 | 详解synchronized的前世今生
54 0