JDK Concurrent包源码阅读1——多线程基础

Posted by 皮皮潘 on 11-25,2021

阻塞其实一共有两种阻塞:1. 能够被中断(interrupt之后抛出InterruptedException)的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING(超时时间到会自动唤醒);2. 而像Synchronized、IO请求等不能被中断的则称为重量级阻塞,对应的状态是BLOCKED。

线程的状态迁移过程如下,总而言之,处于WAITING和TIMED_WAITING的状态收到interrupt命令时会抛出InterruptedException异常,而处于Blocked状态则不会但是会设置对应的interrupt位:
983918385dcaa559aa74d10a0ace16f9.webp

synchronized锁的本质就是Java的对象头,在对象头中有一块叫做MarkWord的数据,记录了锁标志位和占用该所的ThreadID,在该基础上synchronized还会有偏向、自旋等优化措施,而JDK的Lock锁的本质就是是LockSupport.park + CAS,然后再抽象为了AQS框架

使用wait和notify可以基于对象实现通信,使用wait和notify的前提是得先获得对应对象的锁,因为wait和notify本身就需要同步,另外在调用wait时会自动释放对应的锁,不然会由于永远进入不了对应的notify的代码块从而陷入死锁,wait在被notify唤醒时会自动获取对应的锁,wait和notify的底层也是LockSupport.park + CAS

一旦一个共享变量(类的成员变量、 类的静态成员变量) 被 volatile 修饰之后, 那么就具备了三层语义:1. 保证了不同线程对这个变量进行读取时的可见性, 即一个线程修改了某个变量的值, 这新值对其他线程来说是立即可见的。 (volatile 解决了线程间共享变量的可见性问题) 2. 禁止进行指令重排序 3. 64位写入的原子性 ,具体问题如下:

  • 可见性问题:由于存在CPU缓存一致性协议(MESI协议),所以理论上多个CPU之间的缓存不会出现不同步的问题也即可见性问题,但是由于MESI协议对于性能有很大消耗,因此CPU的设计者在这个基础上又进行了各种优化,比如:在CPU和L1之间加Store Buffer,然后缓存的写入直接先写入Store Buffer,然后发送Invalid消息,在等待Invalid消息的响应过程中CPU直接执行其他指令,等到Invalid消息全部响应了,CPU再异步地把数据真正写入L1中,同时根据MESI协议进行缓存同步,也就是说由于引入了Store Buffer导致了在一小段时间内MESI协议不再保证同步性
  • 重排序与内存可见性的关系:重排序共有三种重排序:编译器重排序(对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序)、CPU指令重排序(在指令级别,让没有依赖关系的多条指令并行)、CPU内存重排序(CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致),在这三种重排序中,CPU内存重排序是“内存可见性”问题的主因

编译器和CPU为了程序的性能,会引入各种重排序,但是它们只能保证每个线程内部都是as-if-serial的,对于多个线程互相读取和写入的变量,它们并不考虑,因此为了明确在多线程场景下,什么时候可以重排序,什么时候不能重排序,Java引入了JMM(Java Memory Model),JMM对上是和开发者之间的协商,对下是和编译器、CPU之间的协定。JMM其实是要在开发者写程序的方便性和系统运行的效率之间找到平衡点,一方面要让CPU灵活地重排序,一方面要让开发者了解分别需要和不需要感知什么样的重排序,并且根据对应的影响决定是否通过volatile和synchronized等线程同步机制来禁止重排序。为了描述这个规范,JMM引入了happen-before机制,如果A happen-before B代表如果A比B先执行,那么A的执行结果必须对B可见(写主内存),但是并不代表A一定在B之前发生

JMM承诺四种happen-before的情况且happen-before具有传递性:

  1. 单线程中的每个操作都保证happen-before
  2. 对volatile变量的写入及之前的操作都happen-before后续的读取
  3. 对synchronized的解锁及之前的操作都happen-before对应后续对这个锁的枷锁
  4. 对final变量的写,happen-before 变量所在对象的读,happen-before变量的读

因此相较于C++的volatile,Java的volatile不仅具有内存可见性,还具有禁止重排序的功能

伪共享问题:由于CPU的cache1缓存是每个CPU独有的,且每一行Cache的长度为64Byte,因此即使两个线程改了不同对象比如A.a和A.b,但有时候由于a和b在同一缓存行,这会导致任意一个线程的修改都会导致整行Cache在另外一个线程那里失效,从而需要刷新到内存中才能继续使用,而刷新到内存是一件非常耗时的操作会大大影响程序的性能。对于伪共享问题的解决方案就是缓存行填充,只要将会发生伪共享的每个变量填充满64个Byte就能使多个变量不在同一行缓存行了,JDK8通过提供了注解sum.misc.Contended来在编译时自动实现缓存行填充