多线程基础
JDK
版本:1.8
对于Java
来说,它既不像C++
那样,在运行中调用Linux
的系统API
去fork
出多个线程,也不像Go
那样,在语言层面原生提供多协程。在Java
中并发就是多线程。
在一般的认知中,代码一行一行串行当然最容易理解。但是在多线程下,多个线程的代码交叉并行,多个线程需要并行访问临界资源,线程与线程之间需要相互通信。开发中需要仔细设计线程之间的互斥与同步。
在JDK 1.5
发布之前,Java
只在语言级别层面上提供一些简单的线程互斥与同步机制,也就是synchronized
关键字、Object#wait()
与Object#notify()
。如果遇到复杂的多线程编程场景,开发者就需要基于这些简单的机制解决复杂的多线程同步问题。从JDK 1.5
开始,并发**编程大师Doug Lea
**为Java
源码提供了一个系统而全面的并发编程框架--JDK Concurrent
包即java.util.concurrent
,里面包含了各种原子操作、线程安全的容器、线程池和异步编程等。
1.线程的优雅关闭
线程是一段运行中的代码,或者说是一个运行中的函数。
既然是运行中,就会存在一个基本问题:运行中的线程能否强制杀死?
答案是不能。在
Java
中,有stop()
和destory()
之类的函数,但是这些函数都是Oracle
官方明确不建议使用的。至于原因很简单:如果强制杀死一个正在运行中的线程,则线程运行中所使用的资源,例如文件描述符、网络连接等不能正常关闭并进行释放。
因此,一个线程一旦运行起来,就不要去强行打断它,合理的关闭办法就是让其运行完。干净地释放掉所有资源,然后退出。如果是一个不断循环运行的线程,就需要使用到线程间的通信机制,让主线程通知其退出。
2.守护线程
在下面这段代码中:在main(String[] args)
函数中开启了一个线程,让线程睡眠之前与之后分别打印输出两句话。那么在main()
函数执行完毕退出后,自己创建的线程是否会被强制退出?整个进程是否会被强制退出?
public class DaemonThread {
public static void main(String[] args) {
System.out.println("begin main thread");
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " thread is running now");
try {
Thread.sleep(10000);
System.out.println(Thread.currentThread().getName() + " thread sleep over!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A");
thread.start();
System.out.println("main thread exit");
System.out.println("---------------split line---------------");
}
}
运行之后可以看到如下输出结果:
begin main thread
main thread exit
---------------split line---------------
A thread is running now
A thread sleep over!
可以看到在
main()
函数执行完毕大约10s
之后会打印出A thread sleep over!
这句输出。所以针对上面main()
函数执行完毕之后子线程是否会被强制退出,以及整个线程是否会被强制退出的问题。
答案是:不会。
对于上面的代码,如果在thread.start();
之前加上thread.setDaemon(true);
代码如下:
public class DaemonThread {
public static void main(String[] args) {
System.out.println("begin main thread");
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " thread is running now");
try {
Thread.sleep(10000);
System.out.println(Thread.currentThread().getName() + " thread sleep over!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A");
thread.setDaemon(true);
thread.start();
System.out.println("main thread exit");
System.out.println("---------------split line---------------");
}
}
运行之后可以看到如下结果:
begin main thread
main thread exit
---------------split line---------------
A thread is running now
根据输结果,可以看到当设置thread.setDaemon(true);
后,main()
线程退出后,在main()
方法中新建的thread A
也会跟随主线程的退出而退出,同时整个进程也会退出。
当在一个JVM
进程里面开启多个线程时,这些线程会被分为两类:守护线程与非守护线程。在Java
中默认开启的都是非守护线程。在Java
中有一个规定:但所有的非线程退出后,JVM
整个JVM
的进程就会退出。意思就是守护线程并不会影响JVM
的退出。熟悉垃圾回收线程就是守护线程,在应用运行期间在后台工作,当开发者所有的前台非守护线程都退出之后,整个JVM
进程就退出了。
3.设置关闭的标志位
在实际开发中,一般都会使用标志位来判断线程的关闭状态:
public class StateThread extends Thread {
private boolean stopped;
public StateThread() {
this.stopped = false;
}
public StateThread(boolean stopped) {
this.stopped = stopped;
}
@Override
public void run() {
while (!stopped) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " is transfer State Thread's run method!");
}
}
public void stopStateThread() {
if (!this.stopped) {
this.stopped = true;
}
}
public static void main(String[] args) throws InterruptedException {
StateThread stateThread = new StateThread();
stateThread.start();
Thread.sleep(5000);
// 通知线程 state thread 关闭
stateThread.stopStateThread();
// 等待线程 state thread 退出 while 循环, 让线程自行退出
stateThread.join();
}
}
运行之后可以看到如下结果:
Thread-0 is transfer State Thread's run method!
Thread-0 is transfer State Thread's run method!
Thread-0 is transfer State Thread's run method!
Thread-0 is transfer State Thread's run method!
Thread-0 is transfer State Thread's run method!
可以看出,stateThread
是可以正常退出的。
但是上面代码还是存在一个问题:如果
StateThread
在执行run()
中的while
循环时,线程阻塞到某个地方了,例如调用了Object#wait()
方法,那stateThread
线程可能永远都没有机会再执行while (!stopped)
代码,也就可能一直无法退出当前循环而导致一直阻塞。
此时就需要使用InteruptedException()
与Interrupt()
函数。
4.InterruptedException()
与Interupt()
函数
Interrupt
这个单词是中断的意思,很容易让人产生误解。单纯从字面意思上理解,就好像是说一个线程运行到一半,把它中断了,然后抛出InterruptedException
异常,其实并不是这样。
在如下代码中,假设while
循环中没有调用任何的阻塞函数,就是通常的算数运算:
public class InterruptThread extends Thread {
private boolean stopped;
public InterruptThread() {
this.stopped = false;
}
public void stopInterruptThread() throws InterruptedException {
Thread.sleep(1);
if (!stopped) {
this.stopped = true;
}
}
@Override
public void run() {
while (!stopped) {
int a = 1;
int b = 2;
int c = a + b;
System.out.println("result is : " + c);
}
}
public static void main(String[] args) throws InterruptedException {
InterruptThread interruptThread = new InterruptThread();
interruptThread.start();
interruptThread.interrupt();
interruptThread.stopInterruptThread();
interruptThread.join();
}
}
这时在主线程中调用interruptThread.interrupt();
,该线程并不会抛出异常。
再举个例子,假设这个线程阻塞在一个synchronized
关键字修饰的同步方法,正准备获取锁,代码如下:
@Override
public void run() {
while (!stopped) {
int a = 1;
int b = 2;
int c = a + b;
System.out.println("result is : " + c);
}
}
这时在主线程中调用interruptThread.interrupt();
,该线程也不会抛出异常。
实际上,只有在方法签名上声明了会抛出InterruptedException
的函数才会抛出该异常,也就是如下这些常见的函数:
public final native void wait(long timeout) throws InterruptedException;
public static native void sleep(long millis) throws InterruptedException;
public final void join() throws InterruptedException{}
5.轻量级阻塞与重量级阻塞
在Java
中能够被中断的阻塞称为轻量级阻塞,对应线程的状态为WAITING
或TIMED_WAITING
。而像synchronized
这种不能被中断的阻塞称为重量级阻塞,对应线程的状态为BLOCKED
。如下为线程状态迁移图:
初始线程处于
NEW
状态,调用start()
方法之后线程开始执行,这时线程状态会由NEW
转换为RUNABLE
状态。而在RUNNABLE
又包含RUNNING
和READY
状态,Java
语言中笼统的将RUNNING
和READY
都归纳为RUNNABLE
状态。 如果没有调用任何的阻塞函数,线程只会在
RUNNING
和READY
状态之间切换。也就是系统的时间片调度。线程的这两个状态的切换是操作系统完成的,开发者基本没有任何机会进行介入,除了可以主动调用yield()
方法让当前线程主动放弃CPU
的时间片。 一旦调用了图中任何一个阻塞函数,线程就会进入
WAITING
或TIMED_WAITING
状态。这两个状态的区别只是前者为无限期阻塞,后者则传入一个时间参数,阻塞一个有限的时间。可以这样理解,WAITING
状态可以理解为不见不散,TIMED_WAITING
状态可以理解为过时不候。如果是使用synchronized
关键字函数或者代码块,线程则会进入BLOCKED
状态。 除了常用的线程阻塞 / 唤醒函数,还有一对不太常见的线程阻塞 / 唤醒函数。
LockSupport.park(); LockSupport.unpark();
所以,interrupt();
函数的精确语义是唤醒轻量级阻塞,而不是中断某一个线程。
6.isInterrupted()
与Thread.interrupted()
的区别
因为interrupt()
函数相当于给线程发送一个唤醒的信号,如果线程此时正处于WAITING / TIMED_WAITING
阻塞状态,线程在收到interrupt()
的唤醒信号之后就会抛出一个InterruptedException
,并且线程被唤醒。而如果此时线程并没有被阻塞,那么线程在收到interrupt()
的唤醒信号之后什么都不会做。但在后续,线程可以判断自己是否收到过其它线程发来的中断信号,然后对做一些对应的处理。
isInterrupted()
函数和Thread.interrupted()
函数都是线程用来判断自己是否收到过中断信号,isInterrupted()
是实例方法,而Thread.interrupted()
是静态方法。interrupt()
只是读取线程中断状态,不会修改状态,而Thread.interrupted()
不仅会读取中断状态,还会重置中断标志位。
7.synchronized
关键字
synchronized
关键在在Java
中是用于线程同步的,它有三种使用方式:修饰实例方法、修饰静态方法、修饰代码块。
synchronized
关键字其实就是给某个对象加了把锁。当synchronized
关键字修饰实例方法时,锁是加在实例对象上的。当修饰静态方法时,锁是加在类的Class
对象上。当修饰代码块时,锁是加在synchronized
关键字后括号中的对象上。
synchronized
关键字修饰的同步方法是可重入的,例如当A
线程调用类B
的synchronized
静态同步方法时,在该方法内部又调用了类B
的另外一个synchronized
关键字修饰的静态同步方法,此时线程A
是可以重入类B
的Class
中的对象锁的。
对于synchronized
关键字修饰的实例方法,当有两个线程同时访问某一个类实例的synchronized
方法时,必须进行同步,其中一个线程必须等待另外一个线程执行完毕释放锁后才能够访问该同步方法。而对于不同类实例而言,则不需要进行同步,因为各自获取各自的对象锁进行访问即可。
对于synchronized
关键字修饰的静态方法,当有两个线程同时访问这个静态方法时,必须进行同步,其中一个线程必须等待另外一个线程执行完毕释放锁后才能够访问该静态同步方法。
对于synchronized
关键字修饰实例方法和同步方法,当有两个线程分别访问实例方法和同步方法,不需要进行同步,因为获取的不是同一把锁。
8.锁的本质是什么
多线程要访问同一个资源,线程就是一段运行的代码,资源就是一个变量、一个对象或者一个文件。而锁的作用就是要实现线程对共享资源的访问控制,保证同一个时间只能有一个线程去访问某一个资源。
从应用程序的角度来看,锁其实就是一个对象,这个对象要完成以下几件事情:
- 这个对象内部得有一个标志位
state
变量,记录自己有没有被某个线程占用。简单的情况就是这个state
只有0
和1
两个取值,0
表示没有线程占用,1
表示有某个线程占用了这个锁。 - 如果这个对象被某个线程占用,它得记录这个线程的
thread id
,知道自己被哪个线程占用了。 - 这个对象还需要维护一个
thread id list
,用于记录其它所有阻塞的、等待争取这个锁的线程id
集合。在当前线程释放锁之后,也就是标志位由1
变为0
,线程调度程序从thread id list
中随机选择一个线程唤醒获取该锁。 - 既然锁是一个对象,要访问的共享资源本身也是一个对象,那么这两个对象就看可以合并为一个对象。此时代码就可以变为
synchronized(this){...}
。当然,也可以另外新建一个对象,代码变成synchronized(obj){...}
,这时锁是加在对象obj
上面。
资源与锁合二为一,这使得在Java
中,synchronized
关键字可以加在任何对象的成员上,这意味着这个对象即是共享资源,同时也具备锁的功能。
9.synchronized
实现原理
在JVM
中,程序中创建的对象都会在JVM
的堆内存中开辟一个空间用于创建新生对象,而每个Java
对象的内存结构都分为三部分,分别是对象头Head
、实例数据Instance Data
、对齐填充Padding
。而在对象头中存在一个区域称作运行时数据Mark Word
,这部分用于存放对象的hashCode
、GC
分代年龄、线程持有锁、锁状态标志位、偏向线程id
、偏向时间戳。synchronized
的实现原理就依赖于Mark Word
。
10.wait()
与notify()
生产者-消费者模型是一个常见的多线程编程模型。
在一个内存队列中,多个生产者线程往内存队列中存放数据,多个消费者线程从内存队列中取数据。要想实现这一编程模型,需要做如下几件事情:
- 1、内存队列本身要加锁,才能实现线程安全。
- 2、生产者阻塞,当内存队列满了,生产者无法继续往队列中放数据了,此时生产者线程会被阻塞。消费者阻塞,当内存队列中无数据了,消费者无法继续从队列中获取数据了,此时消费者线程会被阻塞。
- 3、双向通知,当消费者阻塞后,生产者线程有数据放入内存队列中了,生产者队列要
notify()
通知消费者。当生产者阻塞后,消费者线程从队列中获取了数据,此时队列中有空余位置了,消费者线程要notify()
通知生产者线程。第
1
件事情必须做,2
和3
两件事情不一定要做。对于阻塞与双向通知,其实可以换一种方式,当生产者线程放不进数据之后,睡眠几百毫秒再重试,消费者线程获取不到数据之后,睡眠几百毫秒再重试。这个方法效率非常低,并且实时性也不强。所以基于如何阻塞以及双向通知才是比较好的方案。
线程如何进行阻塞:
- 线程自己阻塞自己,也就是生产者线程和消费者线程各自调用
wait()
和notify()
方法。 - 使用阻塞队列用于存放数据,当生产者线程和消费者线程无法从阻塞队列中放入或取出数据时,入队和出队函数本身就是阻塞的,也就是
BlockingQueue
。
如何双向通知:
- 配合使用
wait()
和notify()
函数。 - 使用
Condition
机制。
11.为什么必须和synchronized
关键字一起使用
在Java
中,wait()
和notify()
是Object
类的成员函数。
为什么wait()
和notify()
必须和synchronized
关键字一起使用。
public class A {
private static Object instance = new Object();
public static void funOne() throws InterruptedException {
synchronized (instance) {
System.out.println("function one begin to transfer wait");
instance.wait();
}
}
public static void funTwo() {
synchronized (instance) {
System.out.println("function two begin to transfer notify");
instance.notify();
}
}
public static void main(String[] args) {
new Thread(() -> {
try {
funOne();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> funTwo()).start();
}
}
这里可以开启两个线程,一个线程调用
funOne()
函数,一个线程调用funTwo()
函数。两个线程之间要通信,对于同一个对象来说,一个线程调用该对象的wait()
方法,另一个线程调用该对象的notify()
方法,该对象本身就需要进行同步处理。所以在调用wait()
和notify()
方法之前,要先通过synchronized
关键字同步给对象,也就是给该对象加锁。
在Java
语言中,synchronized
关键字可以加在任何对象的成员函数上,任何对象都可能称为锁,所以wait()
和notify()
方法也要同样普及,也只有存放在Object
类中。
12.为什么wait()
的时候必须释放锁
当一个线程进入synchronized(obj){}
代码块之后,也就是对obj
对象上了锁,此时调用wait()
方法让当前线程进入WAITING
阻塞状态,一直不能退出synchronized(obj){}
代码块,那么另外的线程就永远都无法进入synchronized(obj){}
同步代码块中,永远没有机会调用notify()
方法,这就会造成死锁。
在wait()
方法内部,当前线程调用了对象的wait()
方法之后,会先释放obj
的对象锁,然后当前线程进入阻塞状态,等待其它线程调用notify()
将其唤醒,当阻塞线程被其它线程唤醒之后会重新再去拿锁。调用完wait()
方法之后,线程会继续执行后面的业务逻辑,然后退出synchronized
同步代码块,再次释放锁对象。
wait()
方法内部的伪代码如下:
public ... ... wait(){
// 当前线程释放锁
// 当前线程进入阻塞状态, 等待被其它线程唤醒
// 重新拿对象所
}
13.wait()
与notify()
存在的问题
以生产者–消费者模型来看,伪代码如下:
public void enQueue() {
synchronized (queue) {
// 工作队列满, 无法再继续往工作队列中添加新任务, 生产者线程进入阻塞
while (queue.full()) {
queue.wait();
}
// 入工作队列
// ...
// 通知消费者, 工作队列中有数据了
queue.notify();
}
}
public void deQueue() {
synchronized (queue) {
// 工作队列中没有任务, 消费者线程进入阻塞
while (queue.empty()) {
queue.wait();
}
// 出队列
// ...
// 通知生产者, 工作队列中有空位了
queue.notify();
}
}
可以看到以上伪代码,生产者本来只想通知消费者,但是如果调用queue.notify();
就将其它的生产者也通知了。消费者本只想通知生产者,但是如果调用queue.notify();
就将其它的消费者也通知了。
造成这个现象的原因就是wait()
和notify()
所作用的对象和synchronized
所作用的对象是同一个,只能有一个对象,无法区分空队列和队列满两种场景。这正是Condition
要解决的问题。
14.volatile
关键字
多线程下有这么一个场景,对一个
64
位的long
类型变量的赋值和取值操作,线程A
调用set(100);
,线程B
调用get();
方法获取值,在某些场景下,返回值可能不会是100
。 因为
JVM
的规范并没有要求64
位的long
类型变量或者double
的写入是原子性的。在32
位的机器上,一个64
位变量的写入可能被拆分成两个32
位的写操作来执行。这样就会造成,读取的线程可能就只读取到了一半的值。解决办法也非常简单,使用volatile
关键字修饰变量即可。
15.内存可见性
多线程下对某一个变量的操作,例如存在一个
Integer
类型变量A
,值为100
。线程X
将这个值修改成了50
,而线程Y
可能读取到的还是100
。但是并不是永远都读不到50
,而是说当X
线程修改完之后Y
线程立即去访问。但是一段时候再去读就可以读到100
。这也就是经常说的最终一致性,而不是强一致性。
如果想实现无锁算法,例如一把自旋锁,这时就会出现一个线程将标志位设位置了true
,另外一个线程读到的还是false
,然后两个线程都会拿到同一把锁。
所以,常说的内存可见性,指的就是多线程环境下修改完一个数据之后立即对其它线程可见,它的反面不是不可见,而是稍后才可见,Java
中解决这个问题很容易,使用volatile
关键字修饰变量即可。