Java面试-如何造好synchronized这艘火箭

2022-11-29 23:43:30 浏览数 (3)

一、从线程安全开始

1.1、诱因

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

1.2、锁的内存语义

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

1.3、互斥锁

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

1.4、获取对象锁的方式:

1、同步代码块

代码语言:javascript复制
// 代码示例
/**
 * 方法中有 synchronized(this|object) {} 同步代码块
 */
private void syncObjectBlock1() {
    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、同步非静态方法

代码语言:javascript复制
/**
 * synchronized 修饰非静态方法
 */
private synchronized void syncObjectMethod1() {
    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、同步代码块

代码语言:javascript复制
// 代码示例
private void syncClassBlock1() {
    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、同步静态方法

代码语言:javascript复制
private synchronized static void syncClassMethod1() {
    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实现的,来看看它里面定义了啥?

代码语言:javascript复制
// 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具体在字节码层面的实现,

代码语言:javascript复制
public void syncsTask() {
    // 同步代码块
    synchronized (this) {
        System.out.println("Hello");
    }
}

// 同步方法
public synchronized void syncTask() {
    System.out.println("Hello Again");
}

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

代码语言:javascript复制
// 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(),

代码语言:javascript复制
// 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不会死锁。

代码语言:javascript复制
// 可重入
public void syncsTask() {
    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编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。

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

3.3、锁粗化

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

代码语言:javascript复制
public static 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与线程池相关内容。 期待大家的关注,我们一起前行,定能造成这火箭

0 人点赞