一、多线程
1.1 Thread和Runnable
创建执行线程有两种方法:(1)继承Thread 类;(2)实现Runnable 接口。
Thread类的方式创建新线程:
public class MyThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName() + " 运行了");
try {
Thread.sleep(800);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
实现Runnable接口的方式创建线程:
public class MyRunnable implements Runnable {
@Override
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName() + " 运行了");
try {
Thread.sleep(800);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
}
在Java 中,可以创建两种线程:(1)守护线程。(2)非守护线程。区别在于它们如何影响程序的结束。
Java程序结束执行过程的情形:
(1) 程序执行Runtime类的exit()方法, 而且用户有权执行该方法。
(2) 应用程序的所有非守护线程均已结束执行,无论是否有正在运行的守护线程。
守护线程通常用在作为垃圾收集器或缓存管理器的应用程序中,执行辅助任务。在线程start之前调用isDaemon()方法检查线程是否为守护线程,也可以使用setDaemon()方法将某个线程确立为守护线程。
Thread.States类中定义线程的状态如下:
●NEW:Thread对象已经创建,但是还没有开始执行。
●RUNNABLE:Thread对象正在Java虚拟机中运行。
●BLOCKED : Thread对象正在等待锁定。
●WAITING:Thread 对象正在等待另一个线程的动作。
●TIME_WAITING:Thread对象正在等待另一个线程的操作,但是有时间限制。
●TERMINATED:Thread对象已经完成了执行。
1.1.1 Thread类和Runnable 接口
Runnable接口只定义了一种方法:run()方法。这是每个线程的主方法。当执行start()方法启动新线程时,它将调用run()方法。
Thread类其他常用方法:
●获取和设置Thread对象信息的方法:
getId():该方法返回Thread对象的标识符。该标识符是在钱程创建时分配的一个正整数。在线程的整个生命周期中是唯一且无法改变的。
getName()/setName():这两种方法允许你获取或设置Thread对象的名称。这个名称是一个String对象,也可以在Thread类的构造函数中建立。
getPriority()/setPriority():你可以使用这两种方法来获取或设置Thread对象的优先级。
isDaemon()/setDaemon():这两种方法允许你获取或建立Thread对象的守护条件。
getState():该方法返回Thread对象的状态。
●interrupt():中断目标线程,给目标线程发送一个中断信号,线程被打上中断标记。
●interrupted():判断目标线程是否被中断,但是将清除线程的中断标记。
●isinterrupted():判断目标线程是否被中断,不会清除中断标记。
●sleep(long ms):该方法将线程的执行暂停ms时间。
●join():暂停线程的执行,直到调用该方法的线程执行结束为止。可以使用该方法等待另一个Thread对象结束。
●setUncaughtExceptionHandler():当线程执行出现未校验异常时,该方法用于建立未校验异
常的控制器。
●currentThread():Thread类的静态方法,返回实际执行该代码的Thread对象。
1.1.2 Callable
Callable 接口是一个与Runnable 接口非常相似的接口。Callable 接口的主要特征如下:
●接口。有简单类型参数,与call()方法的返回类型相对应。
●声明了call()方法。执行器运行任务时,该方法会被执行器执行。它必须返回声明中指定类型的对象。
●call()方法可以抛出任何一种校验异常。可以实现自己的执行器并重载afterExecute()方法来处理这些异常。
1.2 synchronized关键字
1.2.1 锁的对象
synchronized关键字“给某个对象加锁”,示例代码:
public Class MyClass {
public void synchronized method1() {
// ...
}
public static void synchronized method2() {
// ...
}
}
等价于:
public class MyClass {
public void method1() {
synchronized(this) {
// ...
}
}
public static void method2() {
synchronized(MyClass.class) {
// ...
}
}
}
实例方法的锁加在对象myClass上;静态方法的锁加在MyClass.class上。
1.2.2 锁的本质
锁是一个“对象”,作用如下:
- 这个对象内部得有一个标志位(state变量),记录自己有没有被某个线程占用。最简单的情况是这个state有0、1两个取值,0表示没有线程占用这个锁,1表示有某个线程占用了这个锁。
- 如果这个对象被某个线程占用,记录这个线程的thread ID。
- 这个对象维护一个thread id list,记录其他所有阻塞的、等待获取拿这个锁的线程。在当前线程释放锁之后从这个thread id list里面取一个线程唤醒。
要访问的共享资源本身也是一个对象,例如前面的对象myClass,这两个对象可以合成一个对象。代码就变成synchronized(this) {…},要访问的共享资源是对象a,锁加在对象a上。当然,也可以另外新建一个对象,代码变成synchronized(obj1) {…}。这个时候,访问的共享资源是对象a,而锁加在新建的对象obj1上。资源和锁合二为一,使得在Java里面,synchronized关键字可以加在任何对象的成员上面。这个对象既是共享资源,同时也具备“锁”的功能!
1.2.3 实现原理
在对象头里,有一块数据叫Mark Word。在64位机器上,Mark Word是8字节(64位)的,这64位中有2个重要字段:锁标志位和占用该锁的thread ID。因为不同版本的JVM实现,对象头的数据结构会有各种差异。
1.3 wait与notify
1.3.1 生产者−消费者模型
一个内存队列,多个生产者线程往内存队列中放数据;多个消费者线程从内存队列中取数据。要实现这样一个编程模型,需要做下面几件事情:
- 内存队列本身要加锁,才能实现线程安全。
- 阻塞。当内存队列满了,生产者放不进去时,会被阻塞;当内存队列是空的时候,消费者无事可做,会被阻塞。
- 双向通知。消费者被阻塞之后,生产者放入新数据,要notify()消费者;反之,生产者被阻塞之后,消费者消费了数据,要notify()生产者。
(1)如何阻塞?
办法1:线程自己阻塞自己,也就是生产者、消费者线程各自调用wait()和notify()。
办法2:用一个阻塞队列,当取不到或者放不进去数据的时候,入队/出队函数本身就是阻塞的。
(2)如何双向通知?
办法1:wait()与notify()机制。
办法2:Condition机制。
能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING;而像synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是 BLOCKED
初始线程处于NEW状态,调用start()开始执行后,进入RUNNING或者READY状态。如果没有调用任何的阻塞函数,线程只会在RUNNING和READY之间切换,也就是系统的时间片调度。这两种状态的切换是操作系统完成的,除非手动调用yield()函数,放弃对CPU的占用。
一旦调用了图中的任何阻塞函数,线程就会进入WAITING或者TIMED_WAITING状态,两者的区别只是前者为无限期阻塞,后者则传入了一个时间参数,阻塞一个有限的时间。如果使用了synchronized关键字或者synchronized块,则会进入BLOCKED状态。
不太常见的阻塞/唤醒函数,LockSupport.park()/unpark()。这对函数非常关键,Concurrent包中Lock的实现即依赖这一对操作原语。
因此thread.interrupted()的精确含义是“唤醒轻量级阻塞”,而不是字面意思“中断一个线程”。
thread.isInterrupted()与Thread.interrupted()的区别
因为 thread.interrupted()相当于给线程发送了一个唤醒的信号,所以如果线程此时恰好处于WAITING或者TIMED_WAITING状态,就会抛出一个InterruptedException,并且线程被唤醒。而如果线程此时并没有被阻塞,则线程什么都不会做。但在后续,线程可以判断自己是否收到过其他线程发来的中断信号,然后做一些对应的处理。
这两个方法都是线程用来判断自己是否收到过中断信号的,前者是实例方法,后者是静态方法。二者的区别在于,前者只是读取中断状态,不修改状态;后者不仅读取中断状态,还会重置中断标志位。
二、并发
在单个处理器上采用单核执行多个任务即为并发。在这种情况下,操作系统的任务调度程序会很快从一个任务切换到另一个任务,因此看起来所有的任务都是同时运行的。
2.1 同步
在并发中,我们可以将同步定义为一种协调两个或更多任务以获得预期结果的机制。同步的方式有两种:
●控制同步:例如,当一个任务的开始依赖于另一个任务的结束时,第二个任务不能在第一个任务完成之前开始。
●数据访问同步:当两个或更多任务访问共享变量时,在任意时间里,只有一个任务可以访问该变量。
与同步密切相关的一个概念是临界段。临界段是一段代码,由于它可以访问共享资源,因此再任何给定时间内,只能被一个任务执行。互斥是用来保证这一要求的机制,而且可以采用不同的方式来实现。
2.2 不可变对象
不可变对象是一种非常特殊的对象。在其初始化后,不能修改其可视状态(其属性值)。如果想修改一个不可变对象,那么就必须创建一个新的对象。
不可变对象的主要优点在于它是线程安全的。你可以在并发应用程序中使用它而不会出现任何问题。
不可变对象的一个例子就是java中的String类。当你给一个String对象赋新值时,会创建一个新的String对象。
2.3 原子操作和原子变量
与应用程序的其他任务相比,原子操作是一种发生在瞬间的操作。在并发应用程序中,可以通过一个临界段来实现原子操作,以便对整个操作采用同步机制。
原子变量是一种通过原子操作来设置和获取其值的变量。可以使用某种同步机制来实现一个原子变量,或者也可以使用CAS以无锁方式来实现一个原子变量,而这种方式并不需要任何同步机制。
2.4 共享内存与消息传递
任务可以通过两种不同的方式来相互通信。第一种方法是共享内存,通常用于在同一台计算机上运行多任务的情况。任务在读取和写入值的时候使用相同的内存区域。为了避免出现问题,对该共享内存的访问必须在一个由同步机制保护的临界段内完成。
另一种同步机制是消息传递,通常用于在不同计算机上运行多任务的情形。当一个任务需要与另一个任务通信时,它会发送一个遵循预定义协议的消息。如果发送方保持阻塞并等待响应,那么该通信就是同步的;如果发送方在发送消息后继续执行自己的流程,那么该通信就是异步的。
三、并发的问题
3.1 数据竞争
如果有两个或者多个任务在临界段之外对一个共享变量进行写入操作,也就是说没有使用任何同步机制,那么应用程序可能存在数据竞争(也叫做竞争条件)。
在这些情况下,应用程序的最终结果可能取决于任务的执行顺序。
3.2 死锁
当两个(或多个)任务正在等待必须由另一线程释放的某个共享资源,而该线程又正在等待必须由前述任务之一释放的另一共享资源时,并发应用程序就出现了死锁。当系统中同时出现如下四种条件时,就会导致这种情形。我们将其称为Coffman 条件。
●互斥: 死锁中涉及的资师、必须是不可共享的。一次只有一个任务可以使用该资源。
●占有并等待条件: 一个任务在占有某一互斥的资源时又请求另一互斥的资源。当它在等待时,不会释放任何资源。
●不可剥夺:资源只能被那些持有它们的任务释放。
●循环等待:任务1正等待任务2 所占有的资源, 而任务2 又正在等待任务3 所占有的资源,以此类推,最终任务n又在等待由任务1所占有的资源,这样就出现了循环等待。
有一些机制可以用来避免死锁。
●忽略它们:这是最常用的机制。你可以假设自己的系统绝不会出现死锁,而如果发生死锁,结果就是你可以停止应用程序并且重新执行它。
●检测:系统中有一项专门分析系统状态的任务,可以检测是否发生了死锁。如果它检测到了死锁,可以采取一些措施来修复该问题,例如,结束某个任务或者强制释放某一资源。
●预防:如果你想防止系统出现死锁,就必须预防Coffman 条件中的一条或多条出现。
●规避:如果你可以在某一任务执行之前得到该任务所使用资源的相关信息,那么死锁是可以规避的。当一个任务要开始执行时,你可以对系统中空闲的资源和任务所需的资源进行分析,这样就可以判断任务是否能够开始执行。
3.3 活锁
如果系统中有两个任务,它们总是因对方的行为而改变自己的状态, 那么就出现了活锁。最终结果是它们陷入了状态变更的循环而无法继续向下执行。
例如,有两个任务:任务1和任务2 ,它们都需要用到两个资源:资源1和资源2 。假设任务1对资源1加了一个锁,而任务2 对资源2 加了一个锁。当它们无法访问所需的资源时,就会释放自己的资源并且重新开始循环。这种情况可以无限地持续下去,所以这两个任务都不会结束自己的执行过程。
3.4 资源不足
当某个任务在系统中无法获取维持其继续执行所需的资源时,就会出现资源不足。当有多个任务在等待某一资源且该资源被释放时,系统需要选择下一个可以使用该资源的任务。如果系统中没有设计良好的算法,那么系统中有些线程很可能要为获取该资源而等待很长时间。
要解决这一问题就要确保公平原则。所有等待某一资源的任务必须在某一给定时间之内占有该资源。可选方案之一就是实现一个算法,在选择下一个将占有某一资源的任务时,对任务已等待该资源的时间因素加以考虑。然而,实现锁的公平需要增加额外的开销,这可能会降低程序的吞吐量。
3.5 优先权反转
当一个低优先权的任务持有了一个高优先级任务所需的资源时,就会发生优先权反转。这样的话,低优先权的任务就会在高优先权的任务之前执行。
四、JMM内存模型
4.1 JMM与happen-before
4.1.1 为什么会存在“内存可见性”问题
下图为x86架构下CPU缓存的布局,即在一个CPU 4核下,L1、L2、L3三级缓存与主内存的布局。每个核上面有L1、L2缓存,L3缓存为所有核共用。
因为存在CPU缓存一致性协议,例如MESI,多个CPU核心之间缓存不会出现不同步的问题,不会有“内存可见性”问题。
缓存一致性协议对性能有很大损耗,为了解决这个问题,又进行了各种优化。例如,在计算单元和L1之间加了Store Buffer、Load Buffer(还有其他各种Buffer),如下图:
L1、L2、L3和主内存之间是同步的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和L1之间却是异步的。向内存中写入一个变量,这个变量会保存在Store Buffer里面,稍后才异步地写入L1中,同时同步写入主内存中。
多CPU,每个CPU多核,每个核上面可能还有多个硬件线程,对于操作系统来讲,就相当于一个个的逻辑CPU。每个逻辑CPU都有自己的缓存,这些缓存和主内存之间不是完全同步的。
对应到Java里,就是JVM抽象内存模型,如下图所示:
4.1.2 重排序与内存可见性的关系
Store Buffer的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器和CPU的指令重排序。
重排序类型:
- 编译器重排序。
对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。 - CPU指令重排序。
在指令级别,让没有依赖关系的多条指令并行。 - CPU内存重排序。
CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。
在三种重排序中,第三类就是造成“内存可见性”问题的主因,如下案例:
线程1:
X=1
a=Y
线程2:
Y=1
b=X
假设X、Y是两个全局变量,初始的时候,X=0,Y=0。这两个线程执行完毕之后,a、b的正确结果应该是什么?
很显然,线程1和线程2的执行先后顺序是不确定的,可能顺序执行,也可能交叉执行,最终正确的结果可能是:1. a=0,b=1;2. a=1,b=0;3. a=1,b=1
也就是不管谁先谁后,执行结果应该是这三种场景中的一种。但实际可能是a=0,b=0。两个线程的指令都没有重排序,执行顺序就是代码的顺序,但仍然可能出现a=0,b=0。原因是线程1先执行X=1,后执行a=Y,但此时X=1还在自己的Store Buffer里面,没有及时写入主内存中。所以,线程2看到的X还是0。线程2的道理与此相同。
虽然线程1觉得自己是按代码顺序正常执行的,但在线程2看来,a=Y和X=1顺序却是颠倒的。指令没有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题。
4.1.3 内存屏障
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理。
编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。
内存屏障是很底层的概念,对于 Java 开发者来说,一般用 volatile 关键字就足够了。但从JDK 8开始,Java在Unsafe类中提供了三个内存屏障函数,如下所示。
public final class Unsafe {
// ...
public native void loadFence();
public native void storeFence();
public native void fullFence();
// ...
}
在理论层面,可以把基本的CPU内存屏障分成四种:
- LoadLoad:禁止读和读的重排序。
- StoreStore:禁止写和写的重排序。
- LoadStore:禁止读和写的重排序。
- StoreLoad:禁止写和读的重排序。
Unsafe中的方法:
- loadFence=LoadLoad+LoadStore
- storeFence=StoreStore+LoadStore
- fullFence=loadFence+storeFence+StoreLoad
4.1.4 as-if-serial语义
- 单线程程序的重排序规则
无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改变,这就是单线程程序的重排序规则。
即只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义。
对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问题。 - 多线程程序的重排序规则
编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。
对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性并据此做出最合理的优化。
编译器和CPU只能保证每个线程的as-if-serial语义。
线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。
上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。
4.1.5 happen-before
如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束。
JMM对编译器和CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。
4.1.6 happen-before的传递性
除了这些基本的happen-before规则,happen-before还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。
4.2 volatile关键字
4.2.1 64位写入的原子性(Half Write)
如,对于一个long型变量的赋值和取值操作而言,在多线程场景下,线程A调用set(100),线程B调用get(),在某些场景下,返回值可能不是100。
public class MyClass {
private long a = 0;
// 线程A调用set(100)
public void set(long a) {
this.a = a;
}
// 线程B调用get(),返回值一定是100吗?
public long get() {
return this.a;
}
}
因为JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。解决办法也很简单,在long前面加上volatile关键字。
4.2.2 重排序:DCL问题
单例模式的线程安全的写法不止一种,常用写法为DCL(Double Checking Locking),如下所示:
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
// 此处代码有问题
instance = new Singleton();
}
}
}
return instance;
}
}
上述的 instance = new Singleton(); 代码有问题:其底层会分为三个操作:
- 分配一块内存。
- 在内存上初始化成员变量。
- 把instance引用指向内存。
在这三个操作中,操作2和操作3可能重排序,即先把instance指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问里面的成员变量,就可能出错。这就是典型的“构造方法溢出”问题。
解决办法也很简单,就是为instance变量加上volatile修饰。
volatile的三重功效:64位写入的原子性、内存可见性和禁止重排序。
4.3 final关键字
4.3.1 构造方法溢出问题
public class MyClass {
private int num1;
private int num2;
private static MyClass myClass;
public MyClass() {
num1 = 1;
num2 = 2;
}
/**
* 线程 A 先执行write()
*/
public static void write() {
myClass = new MyClass();
}
/**
* 线程 B 接着执行read()
*/
public static void read() {
if (myClass != null) {
int num3 = myClass.num1;
int num4 = myClass.num2;
}
}
}
num3和num4的值是否一定是1和2?
num3、num4不见得一定等于1,2。和DCL的例子类似,也就是构造方法溢出问题。
myClass = new MyClass()这行代码,分解成三个操作:
- 分配一块内存;
- 在内存上初始化i=1,j=2;
- 把myClass指向这块内存。
操作2和操作3可能重排序,因此线程B可能看到未正确初始化的值。对于构造方法溢出,就是一个对象的构造并不是“原子的”,当一个线程正在构造对象时,另外一个线程却可以读到未构造好的“一半对象”。
4.3.2 final的happen-before语义
要解决这个问题,不止有一种办法。
办法1:给num1,num2加上volatile关键字。
办法2:为read/write方法都加上synchronized关键字。
如果num1,num2只需要初始化一次,还可以使用final关键字。
之所以能解决问题,是因为同volatile一样,final关键字也有相应的happen-before语义:
- 对final域的写(构造方法内部),happen-before于后续对final域所在对象的读。
- 对final域所在对象的读,happen-before于后续对final域的读。
通过这种happen-before语义的限定,保证了final域的赋值,一定在构造方法之前完成,不会出现另外一个线程读取到了对象,但对象里面的变量却还没有初始化的情形,避免出现构造方法溢出的问题。
4.3.3 happen-before规则总结
- 单线程中的每个操作,happen-before于该线程中任意后续操作。
- 对volatile变量的写,happen-before于后续对这个变量的读。
- 对synchronized的解锁,happen-before于后续对这个锁的加锁。
- 对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。
四个基本规则再加上happen-before的传递性,就构成JMM对开发者的整个承诺。在这个承诺以外的部分,程序都可能被重排序,都需要开发者小心地处理内存可见性问题。