一、从线程安全开始
1.1、诱因
- 存在共享数据(也称临界资源)
- 存在多条线程共同操作这些共享数据 解决的根本办法其实很简单,只要保证同一时刻有且只有一个线程能操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行处理。
以下程序例子能正常退出吗?
public class SynchronizedTest { // 1.创建共享数据 private static boolean flag = true; public static void main(String[] args) throws Exception { Thread t1 = new Thread(() -> { while (true) { // 2、共享数据为false退出循环 // synchronized (SynchronizedTest.class) { if (!flag) { System.out.println("退出t1循环"); break; // } } } }); t1.start(); Thread.sleep(2000L); Thread t2 = new Thread(() -> { flag = false; System.out.println("修改共享数据为false"); }); t2.start(); } }
尝试过后你会发现并不能,控制台只是输出了“修改共享数据为false”。
现在请你放开t1 lambda中的两句注释,程序最终成功退出了。
那么为什么呢?
这是synchronized起作用了,它清空当前线程工作内存上的值,通过将主内存最新值刷新到工作内存中,让各个线程能互相感知修改,锁这个概念由此引入。
1.2、锁的内存语义
线程释放锁,JMM会把该线程中对应的本地内存中的共享变量刷新到主内存中。
线程获取锁,JMM会把线程对应的本地内存置为无效,从而使被监视器保护的临界区代码必须从主内存中读取共享变量。
1.3、互斥锁
- 互斥性:同一时刻只允许一个线程持有某个对象锁,互斥性也成为原子性
- 可见性:确保锁释放之前,对共享数据的修改,对后续获得该锁的线程可见Java中synchronized即为互斥锁,它锁的不是代码,锁的都是对象。
接下去两小节我们先看下synchronized在我们日常开发中出现的位置,它主要分为获取对象锁以及获取类锁,如果你身边有电脑,那么可以试着跑跑这些代码,看看这些代码的执行顺序,然后再看我写的注意点。
1.4、获取对象锁的方式
1、同步代码块
// 代码示例 /** * 方法中有 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、同步非静态方法
/** * 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、同步代码块
// 代码示例 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、同步静态方法
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(); } }
注意:类锁使用的是class对象,并且对象锁和类锁是不会相互干扰的。
二、如何实现synchronized
在前文简单的介绍synchronized的使用方式,这一般也就只会出现在机试中,在大厂的面试中显然是不够的。我们需要了解它的底层实现原理,本章主要围绕synchronized是如何与Java对象头和Monitor一起配合实现锁的,并且一切涉及源码定义以JDK8&&hotspot JVM为叙述基础。
2.1、Java对象头和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具体在字节码层面的实现是如何的,
public void syncsTask() { // 同步代码块 synchronized (this) { System.out.println("Hello"); } } // 同步方法 public synchronized void syncTask() { 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不会死锁。
// 可重入 public void syncsTask() { synchronized (this) { System.out.println("Hello"); synchronized (this){ System.out.println("World"); } } }
三、为何对synchronized嗤之以鼻
谈到synchronized大多数10年开发经验以上的“年长程序员”是不推荐使用的,他们一般推荐使用JUC下的各种Lock,那么为什么呢?主要是,
- 早期JDK的synchronized是重量级锁,依赖于系统的Mutex Lock(互斥)
- 线程之间切换从用户态转换至核心态,开销大。hotspot做了很多的优化,JDK6之后synchronized的性能已经提升。例如,自适应自旋、锁消除、锁粗化、轻量锁、偏向锁等等,使得线程之间更高效的共享数据,解决竞争问题,提高程序执行效率。
下面简单讲讲这些优化吧,也许还能给“年长程序员”科普呢!毕竟JDK的发行版都到18了,它也在进步(手动狗头)。
3.1、自旋锁与自适应自旋锁
许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得。通过让线程处于忙循环等待锁释放,期间不出让CPU,减少线程的切换,该锁在JDK4就被引入。JDK6之后默认开启,处于自旋便会不再挂起线程,但如果锁占用时间过长,就不再推荐使用了,这时候应该通过参数PreBlockSpin参数来更改。自适应自旋锁,自旋的次数不再固定,由前一次在同一个锁上的自旋时间与锁的拥有者状态来决定。
3.2、锁消除
更彻底的优化,JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。
public void add(String str1, String str2) { // StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用 // 因此sb属于不可能共享的资源,JVM会自动消除内部的锁 StringBuffer sb = new StringBuffer(); sb.append(str1).append(str2); }
3.3、锁粗化
JVM对锁的范围进行扩大,减少锁同步的代价。
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与线程池相关内容。