JDK源码阅读:多线程基础

Scroll Down

线程的优雅关闭

stop()与destory()函数

线程是一段运行中的代码,或者说是一个运行中的函数,既然是在运行中,就存在一个最基本的问题,运行到一半的线程能否强制杀死。
答案是肯定不能,在Java中,有stop和destory函数,但这些函数都是官方明确不建议使用的,原因很简单,如果强制杀死线程,则线程中所使用的资源,例如文件描述符、网络连接等不能正常关闭。
因此,一个线程一旦运行起来,就不要去强行打断它,合理的关闭办法是让其运行完,也就是函数执行完毕,干净地释放掉所有资源,然后退出。如果是一个不断循环运行的线程,就需要用到线程间的通信机制,让主线程通知其退出。

守护线程

当在一个JVM线程里面开多个线程时,这些线程被分成两类:守护线程和非守护线程。默认开的是非守护线程。在Java中有一个规定,当所有的非守护线程退出后,整个JVM进程就会退出。意思就是守护线程不算做数,守护进程不影响整个JVM进程的退出。例如,垃圾回收线程就是守护线程,它们在后台默默工作,当开发者的所有前台线程都退出之后,JVM进程就退出了。

InterruptedException()函数与interrupt()函数

只有在那些声明了会抛出InterruptedException的函数才会抛出异常,也就是:

public static native void sleep(long millis) throws InterruptedException;
public final void join() throws InterruptedException;
public final void wait() throws InterruptedException;

轻量级阻塞与重量级阻塞

能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING;而像synchronized这种不能被中断的阻塞称为重量级阻塞,对应的状态是BLOCKED。
jdkThread.png
初始线程处于NEW状态,调用start之后开始执行,进入RUNNING或者READY状态如果没有调用任何的阻塞函数,线程只会在RUNNING和READY之间切换,也就是系统的时间片调度。这两种状态的切换是操作系统完成的,开发者基本没有机会介入,除了调用yield函数,放弃对CPU的占用。
一旦调用了图中任何的阻塞函数,线程就会进入WAITING或者TIMED_WAITING状态,两者的区别只是前者为无限期阻塞,后者则传入了一个时间参数,阻塞一个有限的时间。如果使用了synchronized关键字或者synchronized块,则会进入BLOCKED状态。
除了常用的阻塞/唤醒函数,还有一对不太常见的阻塞/唤醒函数,LockSupport.park()/unpark()。这对函数非常关键,Concurrent包中Lock的实现即依赖这一对操作原语。

t.isInterrupted()与Thread.interrupted()的区别

因为t.interrupted()相当于给线程发送了一个唤醒的信号,所以如果线程此时恰好处于WAITING或者TIMED_WAITING状态,就会抛出一个InterruptedException,并且线程被唤醒。而如果线程此时并没有被阻塞,则线程什么都不会做。但在后续,线程可以判断自己是否收到过其他线程发来的终端信号,然后做一些对应的处理。
这两个函数都是线程用来判断自己是否收到过中断信号的,前者是非静态函数,后者是静态函数。二者的区别在于,前者只是读取中断状态,不修改状态;后者不仅读取中断状态,还会重置中断标志位。

synchronized关键字

锁对象是什么

synchronized关键字其实是给某个对象加了把锁,对于非静态函数,锁会加到对应的函数的对象上。对于静态函数,锁是加载class上面。

锁的本质是什么

从程序角度来看,锁其实就是一个对象,这个对象要完成以下几件事情:

  • 这个对象内部得有一个标志位(state变量),记录自己有没有被某个线程占用。
  • 如果这个对象被某个线程占用,它得记录这个线程的thread ID,知道自己是被哪个线程占用了。
  • 这个对象还得维护一个thread id list,记录其他所有阻塞的,等待拿这个锁的线程。在当前线程释放锁之后,从这个thread id list里面取一个线程唤醒。

synchronized实现原理

Java对象头里,有一块数据叫Mark word。在64为机器上,Mark Word是8字节(64)位的,这64位中有2个重要字段:锁标志位和占用该锁的thread ID。

wait()和notify()

生产者-消费者模型
一个内存队列,多个生产者线程往内存队列中放数据;多个消费者线程从内存队列中取数据。要实现这样一个模型,需要做下面几件事情:

  • 内存队列本身需要加锁,才能实现线程安全。
  • 阻塞。当内存队列满了,生产者放不进去时,会被阻塞;当内存队列是空的时候,消费者无事可做,会被阻塞。
  • 双向通知。消费者被阻塞之后,生产者放入新数据,要notify消费者;反之,生产者被阻塞之后,消费者消费了数据,要notify生产者。

wait()和notify()的问题

wait()和notify()所作用的对象和synchronized所所用的对象是同一个,只能有一个对象,无法区分队列空和队列满两个条件。

volatile关键字

64位写入的原子性

JVM规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到一半的值。解决办法也很简单,在long前面加上volatile关键字。

内存可见性

重排序:DCL问题

volatile的三重功效64位写入的原子性、内存可见性和禁止重排序。

JMM与happen-before

为了明确定义在多线程场景下。什么时候可以重排序,什么时候不能重排序,Java引入了JMM(Java Memory Model),也就是Java内存模型。这个模型就是一套规范,对上,是JVM和开发者之间的协定;对下,是JVM和编译器、CPU之间的协定。
定义这套规范,其实就是要在开发者写程序的方便性和系统运行效率之间找到一个平衡点。一方面,要让编译器和CPU可以灵活地重排序;另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile,synchronized等线程同步机制来禁止重排序。
为了描述这个规范,JMM引入了happen-before,使用happen-before描述两个操作之间的内存可见性。
如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before只确保A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束。
基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:
(1)、单线程中的每个操作,happen-before对应该线程中任意后续操作。
(2)、对volatile变量的写入,happen-before对应后续对这个变量的读取。
(3)、对synchronized的解锁,happen-before对应后续对这个锁的加锁。

内存屏障

为了禁止编译器重排序和CPU重排序,在编译器和CPU层面都有对应的指令,也就是内存屏障。这也正是JMM和happen-before规则的底层实现原理。
编译器通过内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。
而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。

JDK中的内存屏障

内存屏障是很底层的概念,对于Java开发者来说,一般用volatile关键字就足够了。但从JDK9开始,Java在unsafe类中提供了三个内存屏障函数,

public final class Unsafe {
  public native void loadFence();
  public native void storeFence();
  public native void fullFence();
}

要说明的是,这三个屏障并不是最基本的内存屏障。在理论层面,可以把基本的CPU内存屏障分成四种:
(1)、LoadLoad:禁止读和读的重排序。
(2)、StoreStore:禁止写和写的重排序。
(3)、LoadStore:禁止读和写的重排序。
(4)、StoreStore:禁止写和读的重排序。

loadFence = LoadLoad + LoadStore
storeFence = StoreStore + LoadStore
fullFence = loadFence + storeFence + StoreLoad

volatile实现原理

  • 在volatile写操作的前面插入一个StoreStore屏障,保证volatile的写操作不会和之前的写操作重排序。
  • 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后的读操作重排序。
  • 在volatile读操作的后面插入一个LoadLoad屏障 + LoadStore屏障,保证volatile读操作不会和之后的读操作、写操作重排序。

happen-before规则总结

  • 单线程中的每个操作,happen-before于该线程中任意后续操作。
  • 对volatile变量的写,happen-before于后续对这个变量的读。
  • 对synchronized的解锁,happen-before于后续对这个锁的加锁。
  • 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。

四个基本规则加上happen-before的传递性,就构成了JMM对开发者的整个承诺。在这个承诺以外的部分,程序都可能重排序,都需要开发者小心地处理内存可见性的问题。