Java并发(一):多线程干货总结
一、进程 线程
进程:一个进程来对应一个程序,
每个进程对应一定的内存地址空间,并且只能使用它自己的内存空间,各个进程间互不干扰。
进程保存了程序每个时刻的运行状态,这样就为进程切换提供了可能。当进程暂停时,它会保存当前进程的状态(比如进程标识、进程的使用的资源等),在下一次重新切换回来时,便根据之前保存的状态进行恢复,然后继续执行。
对于单核计算机来讲,在同一个时间点上,游戏进程和音乐进程是同时在运行吗?
不是。 因为计算机的 CPU 只能在某个时间点上做一件事。
由于计算机将在“游戏进程”和“音乐进程”之间频繁的切换执行,切换速度极高,人类感觉游戏和音乐在同时进行。 多进程的作用不是提高执行速度,而是提高 CPU 的使用率。
线程:一个进程就包括了多个线程,每个线程负责一个独立的子任务。这些线程是共同享有进程占有的资源和地址空间的。
这样在用户点击按钮的时候,就可以暂停获取图像数据的线程,让UI线程响应用户的操作,响应完之后再切换回来,让获取图像的线程得到CPU资源。从而让用户感觉系统是同时在做多件事情的,满足了用户对实时性的要求。
进程让操作系统的并发性成为可能,而线程让进程的内部并发成为可能。
多线程优点:参考多线程的优点
资源利用率更好
程序设计在某些情况下更简单
程序响应更快
多线程缺点:参考多线程的代价
设计更复杂
上下文切换的开销
增加资源消耗
上下文切换:
对于单核CPU来说(对于多核CPU,此处就理解为一个核),CPU在一个时刻只能运行一个线程,当在运行一个线程的过程中转去运行另外一个线程,这个叫做线程上下文切换(对于进程也是类似)。
由于可能当前线程的任务并没有执行完毕,所以在切换时需要保存线程的运行状态,以便下次重新切换回来时能够继续切换之前的状态运行。
所以一般来说,线程上下文切换过程中会记录程序计数器、CPU寄存器状态等数据。
说简单点的:对于线程的上下文切换实际上就是存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行。
虽然多线程可以使得任务执行的效率得到提升,但是由于在线程切换时同样会带来一定的开销代价,并且多个线程会导致系统资源占用的增加,所以在进行多线程编程时要注意这些因素。
二、创建、启动线程
继承Thread类
public class Test { public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); } } class MyThread extends Thread{ private static int num = 0; public MyThread(){ num++; } @Override public void run() { System.out.println("主动创建的第"+num+"个线程"); } }
实现Runnable接口
public class Test { public static void main(String[] args) { Thread thread = new Thread(new MyRunnable()); thread.start(); } } class MyRunnable implements Runnable{ public MyRunnable() { } @Override public void run() { System.out.println("子线程ID:"+Thread.currentThread().getId()); } }
注意:创建并运行一个线程调用start()方法 而不是run()
三、Thread类/线程状态
1)getId 用来得到线程ID
2)getName和setName 用来得到或者设置线程名称。
3)getPriority和setPriority 用来获取和设置线程优先级。
4)setDaemon和isDaemon
用来设置线程是否成为守护线程和判断线程是否是守护线程。
守护线程和用户线程的区别在于:守护线程依赖于创建它的线程,而用户线程则不依赖。
举个简单的例子:如果在main线程中创建了一个守护线程,当main方法运行完毕之后,守护线程也会随着消亡。而用户线程则不会,用户线程会一直运行直到其运行完毕。在JVM中,像垃圾收集器线程就是守护线程。
5)Thread类有一个比较常用的静态方法currentThread()用来获取当前线程。
6)start方法
7)run方法
8)sleep方法 sleep相当于让线程睡眠,交出CPU,让CPU去执行其他的任务。sleep方法不会释放锁。
9)yield方法
调用yield方法会让当前线程交出CPU权限,让CPU去执行其他的线程。不会释放锁。
调用yield方法并不会让线程进入阻塞状态,而是让线程重回就绪状态。
10)join方法
假如在main线程中,调用myThread.join()方法,则main方法会等待thread线程执行完毕或者等待一定的时间。
如果调用的是无参join方法,则等待thread执行完毕,如果调用的是指定了时间参数的join方法,则等待一定的事件。
11)interrupt方法
详细讲解中断:
一行一行源码分析清楚 AbstractQueuedSynchronizer (二)
单独调用interrupt方法可以使得处于阻塞状态的线程抛出一个异常,它可以用来中断一个正处于阻塞状态的线程。
举例:
控制台输出:
进入睡眠状态
得到中断异常
run方法执行完毕
public class Test { public static void main(String[] args) throws IOException { Test test = new Test(); MyThread thread = test.new MyThread(); thread.start(); try { Thread.currentThread().sleep(2000); } catch (InterruptedException e) { } thread.interrupt(); } class MyThread extends Thread{ @Override public void run() { try { System.out.println("进入睡眠状态"); Thread.currentThread().sleep(10000); System.out.println("睡眠完毕"); } catch (InterruptedException e) { System.out.println("得到中断异常"); } System.out.println("run方法执行完毕"); } } }
另:wait() notify() notifyAll()是Object类的方法
12)wait()
线程阻塞,JVM将该线程放置在目标对象的等待集合中。
释放调用wait()对象的同步锁,但是除此之外的其他锁依然由该线程持有。
即使是在wait()对象多次嵌套同步锁,所持有的可重入锁也会完整的释放。这样,后面恢复的时候,当前的锁状态能够完全地恢复。
object.wait() object.notify() object.notifyAll() 调用之前需要先拿到object锁。
13)notify()
Java虚拟机从目标对象的等待集合中随意选择一个线程(称为T,前提是等待集合中还存在一个或多个线程)并从等待集合中移出T。当等待集合中存在多个线程时,并没有机制保证哪个线程会被选择到。
调用notify()的线程释放锁,线程T竞争锁,如果竞争到锁,线程T从之前wait的点开始继续执行。
14)notifyAll()
notifyAll方法与notify方法的运行机制是一样的。对象等待集合中的所有线程都移出,进入可运行状态。
15)LookSupport.park()和unpark()
LockSupport类是Java6(JSR166-JUC)引入的一个类,提供了基本的线程同步原语。LockSupport实际上是调用了Unsafe类里的函数,归结到Unsafe里,只有两个函数:
public native void park(boolean isAbsolute, long time); 挂起
public native void unpark(Thread jthread); 唤醒
unpark函数为线程提供“许可(permit)”,park函数则等待“许可”。这个有点像信号量,但是这个“许可”是不能叠加的,“许可”是一次性的。
比如线程B连续调用了三次unpark函数,当线程A调用park函数就使用掉这个“许可”,如果线程A再次调用park,则进入等待状态。
注意,unpark函数可以先于park调用。比如线程B调用unpark函数,给线程A发了一个“许可”,那么当线程A调用park时,它发现已经有“许可”了,那么它会马上再继续运行。
park/unpark模型真正解耦了线程之间的同步,线程之间不再需要一个Object或者其它变量来存储状态,不再需要关心对方的状态。(JUC中用到)
Thread类源码剖析 中对Thread介绍更加详细。
四、线程安全
竞态条件:当多个线程同时访问同一个资源,其中的一个或者多个线程对这个资源进行了写操作,对资源的访问顺序敏感,就称存在竞态条件。多个线程同时读同一个资源不会产生竞态条件。
临界区:导致竞态条件发生的代码区称作临界区。在临界区中使用适当的同步就可以避免竞态条件。
基本上所有的并发模式在解决线程安全问题时,都采用“序列化访问临界资源”的方案,即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
通常来说,是在访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其他线程继续访问。
在Java中,提供了两种方式来实现同步互斥访问:synchronized和Lock。
五、Java同步块(synchronized block)
Java中的同步块用synchronized标记。
同步块在Java中是同步在某个对象上(监视器对象)。
所有同步在一个对象上的同步块在同时只能被一个线程进入并执行操作。
所有其他等待进入该同步块的线程将被阻塞,直到执行该同步块中的线程退出。
(注:不要使用全局对象(常量等)做监视器。应使用唯一对应的对象)
public class MyClass { int count; // 1.实例方法 public synchronized void add(int value){ count += value; } // 2.实例方法中的同步块 (等价于1) public void add(int value){ synchronized(this){ count += value; } } // 3.静态方法 public static synchronized void add(int value){ count += value; } // 4.静态方法中的同步块 (等价于3) public static void add(int value){ synchronized(MyClass.class){ count += value; } } }
六、线程通信
线程通信的目标是使线程间能够互相发送信号。另一方面,线程通信使线程能够等待其他线程的信号。
通过共享对象通信
// 必须是同一个MySignal实例 public class MySignal{ protected boolean hasDataToProcess = false; public synchronized boolean hasDataToProcess(){ return this.hasDataToProcess; } public synchronized void setHasDataToProcess(boolean hasData){ this.hasDataToProcess = hasData; } }
wait() – notify()/notifyAll()
// A线程调用doWait()等待, B线程调用doNotify()唤醒A线程 public class MyWaitNotify{ MonitorObject myMonitorObject = new MonitorObject(); public void doWait(){ synchronized(myMonitorObject){ try{ myMonitorObject.wait(); } catch(InterruptedException e){...} } } public void doNotify(){ synchronized(myMonitorObject){ myMonitorObject.notify(); } } }
问题一 信号丢失:先调用了notify()后调用wait(),线程会一直等待下去
// 增加boolean wasSignalled, 记录是否收到唤醒信号。只有没收到过唤醒信号时才可以wait,避免信号丢失。 public class MyWaitNotify2 { MonitorObject myMonitorObject = new MonitorObject(); boolean wasSignalled = false; public void doWait(){ synchronized(myMonitorObject){ if(!wasSignalled){ try{ myMonitorObject.wait(); } catch(InterruptedException e){...} } wasSignalled = false;// wait+notify之后将信号清除 } } public void doNotify() { synchronized (myMonitorObject) { wasSignalled = true; myMonitorObject.notify(); } } }
问题二 虚假信号:
假设线程A1因为某种条件在条件队列中等待,同时线程A2因为另外一种条件在同一个条件队列中等待,也就是说线程A1/A2都被同一个Object.wait()挂起,但是等待的条件不同。
此时满足A2的条件,允许线程B执行一个Object.notify()操作去唤醒A2,但是JVM从Object.wait()的多个线程(A1/A2)中随机挑选一个唤醒,可能唤醒了A1。
A1线程即使没有收到正确的信号,也能够执行后续的操作。A1收到的就是虚假信号。
而此时A2仍然在傻傻的等待被唤醒的信号。A2则信号丢失。
public class MyWaitNotify3 { MonitorObject myMonitorObject = new MonitorObject(); boolean wasSignalled = false; public void doWait(){ synchronized(myMonitorObject){ while(!wasSignalled){ try{ myMonitorObject.wait();// 如果被虚假唤醒,再回while循环检查条件wasSignalled } catch(InterruptedException e){...} } wasSignalled = false; } } public void doNotify() { synchronized (myMonitorObject) { wasSignalled = true; myMonitorObject.notify(); } } }
这样的一个while循环叫做自旋锁(注:这种做法要慎重,目前的JVM实现自旋会消耗CPU,如果长时间不调用doNotify方法,doWait方法会一直自旋,CPU会消耗太大)
问题三 多个线程等待相同信号,notifyAll() (while好处)
如果有多个线程在等待,被notifyAll()唤醒,第一个获得锁的线程被正确执行。
用if():其它线程获得锁后,不管条件wasSignalled是否满足都会直接wait往后执行;
用while():其它线程获得锁后先检查条件wasSignalled,如果不满足就继续wait
(注:不要使用全局对象(常量等)做监视器。应使用唯一对应的对象)
总之,用while()自旋锁,线程被唤醒之后可以保证再次检查条件是否满足。
七、死锁
多个线程同时但以不同的顺序请求同一组锁的时候可能死锁。
如果线程1锁住了A,然后尝试对B进行加锁,同时线程2已经锁住了B,接着尝试对A进行加锁,这时死锁就发生了。
如果线程1稍微领先线程2,然后成功地锁住了A和B两个对象,那么线程2就会在尝试对B加锁的时候被阻塞,这样死锁就不会发生。因为线程调度通常是不可预测的,因此没有一个办法可以准确预测什么时候死锁会发生,仅仅是可能会发生。
/** * 如果线程1调用parent.addChild(child)方法的同时有另外一个线程2调用child.setParent(parent)方法, * 两个线程中的parent表示的是同一个对象,child亦然,此时就可能发生死锁。 */ public class TreeNode { TreeNode parent = null; List children = new ArrayList(); public synchronized void addChild(TreeNode child) {// parent对象锁 if (!this.children.contains(child)) { this.children.add(child); child.setParentOnly(this);// child对象锁 } } public synchronized void addChildOnly(TreeNode child){// parent对象锁 if(!this.children.contains(child){ this.children.add(child); } } public synchronized void setParent(TreeNode parent) {// child对象锁 this.parent = parent; parent.addChildOnly(this);// parent对象锁 } public synchronized void setParentOnly(TreeNode parent) {// child对象锁 this.parent = parent; } }
更复杂的死锁
死锁可能不止包含2个线程。四个线程死锁的例子:
Thread 1 locks A, waits for B
Thread 2 locks B, waits for C
Thread 3 locks C, waits for D
Thread 4 locks D, waits for A
线程1等待线程2,线程2等待线程3,线程3等待线程4,线程4等待线程1。
避免死锁
1、加锁顺序
多个线程请求的一组锁按顺序加锁可以避免死锁。
比如解决:如果线程1锁住了A,然后尝试对B进行加锁,同时线程2已经锁住了B,接着尝试对A进行加锁,这时死锁就发生了。
线程1和线程2都先锁A再锁B,不会发生死锁。
问题:这种方式需要你事先知道所有可能会用到的锁,并对这些锁做适当的排序。
2、加锁时限(超时重试机制)
设置一个超时时间,在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求,回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。
这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行干点其它事情。
问题:1)当线程很多时,等待的这一段随机的时间会一样长或者很接近, 因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争,带来了新的问题。
2)不能对synchronized同步块设置超时时间。需要创建一个自定义锁,或使用java.util.concurrent包下的工具。
3、死锁检测
主要是针对那些不可能实现按序加锁并且锁超时也不可行的情况。
每当一个线程获得了锁获请求锁,会在线程和锁相关的数据结构中(比如map)将其记下。当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。
例如:线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。
当检测出死锁时,可以有两种做法:
1)释放所有锁,回退,并且等待一段随机的时间后重试。(类似超时重试机制)
2)给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。
八、饥饿和公平
如果一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间,这种状态被称之为饥饿。
解决饥饿的方案被称之为公平性 – 即所有线程均能公平地获得运行机会。
导致线程饥饿原因:
1)高优先级线程吞噬所有的低优先级线程的CPU时间。
2)线程始终竞争不到锁。
3)线程调用object.wait()后没有被唤醒。
Java中实现公平性方案
使用锁方式替代同步块(不能实现公平性)
/** * 第一个线程调用lock(),isLocked=true * 之后的线程调用lock(),都被wait() * 第一个线程执行完释放锁,调用unlock()。isLocked = false允许其它线程拿锁,notify()唤醒一个线程 */ public class Lock { private boolean isLocked = false; private Thread lockingThread = null; public synchronized void lock() throws InterruptedException { while (isLocked) { wait(); } isLocked = true; lockingThread = Thread.currentThread(); } public synchronized void unlock() { if (this.lockingThread != Thread.currentThread()) { throw new IllegalMonitorStateException("Calling thread has not locked this lock"); } isLocked = false; lockingThread = null; notify(); } }
公平锁:
/** * 第一个线程调用lock(),isLocked=true * 之后的线程调用lock()拿锁,new queueObject()存入队列并wait() * 第一个线程执行完毕调用unlock(),isLocked=false,取出队列头部的queueObject对象notify(),保证了先wait的线程按顺序先notify(即公平性) * * 1.synchronized的同步块走完就会释放锁,没有嵌套,不存在死锁和嵌套锁死 */ public class FairLock { private boolean isLocked = false; private Thread lockingThread = null; private List<QueueObject> waitingThreads = new ArrayList<QueueObject>(); public void lock() throws InterruptedException { QueueObject queueObject = new QueueObject(); boolean isLockedForThisThread = true; synchronized (this) { waitingThreads.add(queueObject); } while (isLockedForThisThread) { synchronized (this) { isLockedForThisThread = isLocked || waitingThreads.get(0) != queueObject; if (!isLockedForThisThread) { isLocked = true; waitingThreads.remove(queueObject); lockingThread = Thread.currentThread(); return; } } try { queueObject.doWait(); } catch (InterruptedException e) { synchronized (this) { waitingThreads.remove(queueObject); } throw e; } } } public synchronized void unlock() { if (this.lockingThread != Thread.currentThread()) { throw new IllegalMonitorStateException("Calling thread has not locked this lock"); } isLocked = false; lockingThread = null; if (waitingThreads.size() > 0) { waitingThreads.get(0).doNotify(); } } } public class QueueObject { private boolean isNotified = false; public synchronized void doWait() throws InterruptedException { while (!isNotified) { this.wait(); } this.isNotified = false; } public synchronized void doNotify() { this.isNotified = true; this.notify(); } public boolean equals(Object o) { return this == o; } }
九、嵌套管程锁死
线程1获得A对象的锁。
线程1获得对象B的锁(A对象锁还未释放)。
线程1调用B.wait(),从而释放了B对象上的锁,但仍然持有对象A的锁。
线程2需要同时持有对象A和对象B的锁,才能向线程1发信号B.notify()。
线程2无法获得对象A上的锁,因为对象A上的锁当前正被线程1持有。
线程2一直被阻塞,等待线程1释放对象A上的锁。
线程1一直阻塞,等待线程2的信号,因此不会释放对象A上的锁。
举例:
public class Lock { protected MonitorObject monitorObject = new MonitorObject(); protected boolean isLocked = false; public void lock() throws InterruptedException { synchronized (this) { while (isLocked) { synchronized (this.monitorObject) { this.monitorObject.wait(); } } isLocked = true; } } public void unlock() { synchronized (this) { this.isLocked = false; synchronized (this.monitorObject) { this.monitorObject.notify(); } } } }
十、滑动条件(Slipped Conditions)
一个线程检查某一条件到该线程操作此条件期间,这个条件已经被其它线程改变,导致第一个线程在该条件上执行了错误的操作。
/** * 两个线程同时调用lock() * 第一个线程执行到两个同步块之间时,此时isLocked=false * 第二个线程开始执行lock(),会跳过while循环,走出第一个同步块。(正确执行:第一个线程lock了,第二个线程要wait) */ public class Lock { private boolean isLocked = false; public void lock() { synchronized (this) { while (isLocked) { try { this.wait(); } catch (InterruptedException e) { // do nothing, keep waiting } } } synchronized (this) { isLocked = true; } } public synchronized void unlock() { isLocked = false; this.notify(); } }
为避免slipped conditions,条件的检查与设置必须是原子的,也就是说,在条件检查和设置期间,不会有其它线程操作这个条件。
修改:
public class Lock { private boolean isLocked = false; public void lock() { synchronized (this) { while (isLocked) { try { this.wait(); } catch (InterruptedException e) { // do nothing, keep waiting } } isLocked = true;// isLocked的检查和修改要具有原子性 } } public synchronized void unlock() { isLocked = false; this.notify(); } }
十一、重入锁死
如果一个线程持有某个对象上的锁,那么它就有权访问所有在该对象上同步的块。这就叫可重入。若线程已经持有锁,那么它就可以重复访问所有使用该锁的代码块。
重入锁死举例:
/** * 如果一个线程两次调用lock()间没有调用unlock()方法,那么第二次调用lock()就会被阻塞,这就出现了重入锁死。 */ public class Lock { private boolean isLocked = false; public synchronized void lock() throws InterruptedException { while (isLocked) { wait(); } isLocked = true; } public synchronized void unlock() { isLocked = false; notify(); } }
十二、并发编程模型
并行工作者模型
在并行工作者模型中,委派者(Delegator)将传入的作业分配给不同的工作者。每个工作者完成整个任务。工作者们并行运作在不同的线程上,甚至可能在不同的CPU上。
如果在某个汽车厂里实现了并行工作者模型,每台车都会由一个工人来生产。工人们将拿到汽车的生产规格,并且从头到尾负责所有工作。
优点:容易实现,容易理解
缺点:
(1)共享状态可能会很复杂。
线程需要以某种方式存取共享数据,以确保某个线程的修改能够对其他线程可见(数据修改需要同步到主存中,不仅仅将数据保存在执行这个线程的CPU的缓存中)。
在等待访问共享数据结构时,线程之间的互相等待将会丢失部分并行性。
非阻塞并发算法和可持久化的数据结构 可以降低竞争并提升性能,但是实现比较困难。
(2)共享状态能够被系统中得其他线程修改。所以线程在每次需要的时候必须重读状态,会导致速度变慢,特别是状态保存在外部数据库中的时候。
(3)任务顺序是不确定的
流水线模式
每个工作者只负责作业中的部分工作。当完成了自己的这部分工作时工作者会将作业转发给下一个工作者。每个工作者在自己的线程中运行,并且不会和其他工作者共享状态。
Actors 和 channels 是两种比较类似的流水线(或反应器/事件驱动)模型。
优点:
(1)工作者之间无需共享状态,意味着实现的时候无需考虑所有因并发访问共享对象而产生的并发性问题。
这使得在实现工作者的时候变得非常容易。在实现工作者的时候就好像是单个线程在处理工作-基本上是一个单线程的实现。
(2)当工作者知道了没有其他线程可以修改它们的数据,工作者可以变成有状态的。
它们可以在内存中保存它们需要操作的数据,只需在最后将更改写回到外部存储系统。因此,有状态的工作者通常比无状态的工作者具有更高的性能。
(3)较好的硬件整合,更好的利用缓存。
(4)实现一个有保障的作业顺序。
缺点:作业的执行往往分布到多个工作者上,并因此分布到项目中的多个类上。编码难度大。
函数式并行
函数都是通过拷贝来传递参数的,所以除了接收函数外没有实体可以操作数据。这对于避免共享数据的竞态来说是很有必要的。同样也使得函数的执行类似于原子操作。每个函数调用的执行独立于任何其他函数的调用。
一旦每个函数调用都可以独立的执行,它们就可以分散在不同的CPU上执行了。这也就意味着能够在多处理器上并行的执行使用函数式实现的算法。