1 多线程基础
1 多线程基础
1.1 线程的优雅关闭
1.1.1 stop 与 destory 函数
不要强行打断线程,合理的关闭办法是让其运行完,干净地释放掉所有资源。
1.1.2 守护线程
C 语言:main 函数退出后,整个程序也就退出了。
Java 中;当所有的非守护线程退出后,整个 JVM 进程就会退出,详见代码 com/study/java/concurrent/chapter01/DaemonThreadTest.java
。
1.1.3 设置关闭的标志位
一般用 while(flag)
标志位来跳出死循环。
1.2 InterruptedException() 函数与 interupt() 函数
1.2.1 什么情况下会抛出 interrupted 异常
在调用 interupt() 函数后,只有那些声明了会抛出 InterruptedException 的函数才会抛出异常,也就是下面的这些函数
public static native void sleep(long millis) throws InterruptedException{}
public final void wait() throws InterruptedException{}
public final void join() throws InterruptedException{}
1.1.2.2 轻量级阻塞与重量级阻塞
轻量级阻塞:能够被线程中断,对应的线程状态是 WAITING 或者 TIME_WAITING。
重量级阻塞:如 syncrhonized 不能被中断的阻塞,对应的状态是 BLOCKED。
一个线程完整的状态迁移过程如下图。
1.2.3 t.isInterrupted() 与 Thread.interrupted() 的区别
前者是非静态函数,后者是静态函数。二者的区别在于,前者只是读取终端状态,不修改状态;后者不仅读取中断状态,还会重置中断标志位。
1.2.4 聊聊线程中断 interrupt(),isInterrupted(),interrupted() 三者区别(拓展)
- interrupt() 是给线程设置中断标志;
- interrupted() 是检测中断并清除中断状态;
- isInterrupted() 只检测中断。
另重要的一点就是 interrupted() 作用于当前线程,interrupt() 和 isInterrupted() 作用于此线程,即代码中调用此方法的实例所代表的线程。
注意:此流程细节较多,可以查看聊聊线程中断interrupt(),isInterrupted(),interrupted()三者区别,详见代码 com/study/java/concurrent/chapter01/InterruptTest.java
。
1.3 synchronized 关键字
1.3.1 锁的对象是什么
以一个问题来理解,一个静态成员函数和一个非静态成员函数,都加了 synchronized
关键字,分别被两个线程调用,他们是否互斥?
对于非静态成员函数,锁是加在对应上的;对于静态成员函数,锁是加在 Class.class
上面的,当然,class 本身也是对象。很显然,因为是两把不同的锁,所以不会互斥。
1.3.2 锁的本质是什么
从程序角度来看,锁其实就是一个对象,这个对象要完成以下几个事情:
- 这个对象有一个标志位,记录自己有没有被某个线程占用。
- 如果这个对象被某个线程占用,它得记录这个线程的 thread id。
- 这个对象还得维护一个 thread id list,记录其他所有阻塞的,等待拿这个锁的线程。
1.3.3 synchronized 实现原理
通过 java 的对象头实现。在对象头里,有一块数据叫 Mark Word。在 64 位机器上,Mark Word 是 8 字节(64)位的,这 64 位中有 2 个重要字:锁标志位和占用该锁的thread id。因为不同版本的 JVM 实现,对象头的数据结构会有各种差异,这里不展开讨论,后续 JVM 小节再研究下细节。
TIP
java 里面,资源和锁合二为一了,synchronized 关键字可以加在任何对象的成员上面。
1.4 wait() 与 notify()
1.4.1 生产者消费模型
生产者消费模型,需要做以下几个事情:
- 必须要做。内存队列本身要加锁,才能实现线程安全。
- 选做。阻塞。当内存队列满了,生产者放不进去时,会被阻塞;当内存队列是空的时候,消费者无事可做,会被阻塞。
- 选做。双向通知。消费者被阻塞之后,生产者放入新数据,要 notify 消费者;反之,生产者被阻塞之后,消费者消费量数据,要 notify 生产者。
如何阻塞:
- 线程自己阻塞自己,也就是生产者、消费者线程各自调用 wait() 和 notify()。
- 用一个阻塞队列,当取不到或者放不进去数据的时候,入队/出队函数本身就是阻塞的。这也就是 BlockingQueue 的实现。
如何双向通知:
- wait() 和 notifty 机制。
- Condition 机制。
1.4.2 为什么必须和 synchronized 一起使用
两个线程之间要通信,对于一个对象来说,一个对象调用该对象的 wait(),另一个对象调用该对象的 notify(),该对象本身就需要同步!所以,在调用 wait()、notify() 之前,要先通过 synchronized 关键字同步给对象,也就是给该对象加锁。
TIP
为什么 Java 要把 wait() 和 notify() 放在 Object 中? synchronized 关键字可以加在任何对象的成员函数上面,任何对象都可能成为锁。wait() 和 nofity() 也同样要如此普及,也只能放在 Object 里面了。
1.4.3 为什么 wait() 的时候必须释放锁
简单来说就是避免死锁。
1.4.4 wait() 与 notify() 的问题
示例代码见 com/study/java/concurrent/chapter01/ProducerModel01Test.java
。wait() 无法解决队列满时通知生产者还是消费者线程场景,需要通过 Condition 来解决。
1.5 volatile 关键字
1.5.1 64 位写入的原子性(Half Write)
在 32 位的机器上,一个 64 位变量的写入可能被拆分成两个 32 位的写操作来执行。这样会导致读取的线程就可能读到“一半”的值,在 long 前面加上 volatile 即可解决。
1.5.2 内存可见性
内存可见性问题与现代 CPU 架构密切相关,简单来说就是一个线程写完变量为 true,但是另外一个线程读到的还是 false。
1.5.3 重排序:DCL 问题
DCL 即(Double Checking Locking)。在双重检查的 "instance = new Single()" 里面,其底层会分为三个操作:
- 分配一块内存。
- 在内存是初始化成员变量。
- 把 instance 引用指向内存。
其中 2、3 可能重排序,导致双重检查失效,多次 new 对象,这也是典型的“构造函数溢出”问题。加上 volatile 修饰即可解决。
“构造函数溢出”另外一个例子如构造函数里面发布事件,this 对象给外部,然后 this 因为重排序时还为 null。
1.5.4 volatile 的作用(拓展)
如上,汇总如下。
- 64 位写入的原子性,其实就是因为第2点特性保证的。如在 long、double 前面加上 volatile 关键字。
- 保证内存的可见性。
- 禁止指令重排序。
1.6 JMM 与 happen-before
1.6.1 为什么会存在“内存可见性问题”
需要了解现代 CPU 的架构,可以参考Java并发编程(1)-并发基础。 对应到 Java 里面,就是 JVM 抽象内存模型,如下图所示。

JVM 在设计时候考虑到,如果 JAVA 线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为 JMM 制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。
1.6.2 重排序与内存可见性的关系
重排序有以下几种类型:
- 编译器重排序。在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- CPU 指令重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- CPU 内存重排序。处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
在三种重排序中,第三类就是造成“内存可见性”问题的主因。
1.6.3 as-if-serial
不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个 as-if-serial 的概念。
as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。
编译器、runtime 和处理器都必须遵守 as-if-serial 语义。为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。
1.6.4 happen-before 是什么
Happen-Before 被翻译成先行发生原则,意思就是当 A 操作先行发生于 B 操作,则在发生 B 操作的时候,操作 A 产生的影响能被 B 观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。
1.6.5 happen-before 的传递性
注意 happer-before 传递性对非 votile 变量也是有效的。
1.6.6 C++ 的 volatile
Java 会禁止 volatile 变量写入和非 volatile 变量写入的重排序,但是 C++ 不会。
1.6.7 JSR-133 对 volatile 语义的增强
即上小节说的是 Java 对 happer-before 规则的严格遵守。
1.7 内存屏障
禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令。其正是 JMM 和 happen-before 规则的底层实现原理。
- 编译器内存屏障:只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU 并不会感知到编译器中内存屏障的存在。
- CPU 的内存屏障:CPU 提供的指令,可以由开发者显示调用。
1.7.1 Linux 中的内存屏障
略。书中例举了一段源码说明。
TIP
这里 C 语言代码看得不是很懂,有空还得研究下。
1.7.2 JDK 中的内存屏障
在理论层面上,可以把基本的 CPU 内存屏障分成四种:
- LoadLoad:禁止读和读的重排序。
- StoreStore:禁止写和写的重排序。
- LoadStore:禁止读和写的重排序。
- StoreLoad:禁止写和读的重排序。
Java 从 JDK8 开始,在 Unsafe 类中提供了三个内存屏障函数:
public final class Unsafe {
// 根据 JDK9 的注释,可以知道
// loadFence = LoadLoad + LoadStore
public native void loadFence();
// storeFence = StoreStore + LoadStore
public native void storeFence();
// fullFence = loadFence + storeFence + StoreLoad
public native void fullFence();
}
1.7.3 valatile 实现原理
不同的 CPU 架构的缓存体系不一样,重排序的策略不一样,所提供的内存屏障指令也有所差异,下面是一种参考做法:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障,避免 volatile 写与上面的写操作重排序;
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障,避免 volatile 写与下面的读操作重排序;
- 在每个 volatile 读操作的前面插入一个 LoadLoad 屏障,避免 volatile 读与上面的读操作重排序;
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障,避免 volatile 读与下面的写操作重排序;
实现原理:
- 将当前处理器缓存行的数据写回到系统内存。
- 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
1.8 final 关键字
1.8.1 构造函数溢出问题
1.8.2 final 的 happen-before 语义
final 可以解决只初始化一次,保证读在写操作之后。
1.8.3 happen-before 规则总结
- 单线程中的每个操作,happen-before 于该线程中任意后续操作。
- 对 volatile 变量的写,happen-before 于后续对这个变量的读。
- 对 synchronized 的解锁,happen-before 于后续对这个锁的加锁。
- 对 final 变量的写,happen-before 于 final 域对象的读,happen-before 于后续对 final 变量的读。
四个基本规则再加上 happen-before 的传递性,就构成 JMM 对开发者的整个承诺。
1.9 无锁编程
详见作者的另外一本书《软件架构设计:大型网站技术架构与业务架构融合之道》。以下是一些简单无锁编程的场景。