一、进程
在多进程设计中各进程之间的数据块是相互独立,彼此通过信号、管道进行通信。而在多线程设计中,各线程不一定独立,同一任务中的各线程共享程序段、数据段等资源。Java通过Package类(Java.lang.package)支持多进程,通过Thread类支持多线程。
二、线程
多线程既有生产者消费者,哲学家就餐,读写器或者简单的有界缓冲区等应用问题。也有死锁,竞态条件,内存冲突和线程安全等并发问题。
为方便写出线程安全的程序,Java提供线程安全类和并发工具,比如:同步容器、并发容器、队列、同步工具类。
三、多线程环境解决方案及原理
基本上所有的并发模式都是采用串行访问共享资源的方案解决线程冲突问题。
Java语言的同步机制
,在底层
实现有两种方式:互斥
和协同
。在语言层面
,就是内置锁synchronized
和内置条件队列
,即Object的wait(),notify(),notifyAll()方法。
显式锁为Lock,显示条件队列为Condition对象。
@TreadSafe
public class BoundedBuffer extends BaseBoundedBuffer{
public BoundedBuffer(int size){
super(size);
}
public synchronized void put(V v) throws InterruptedException{
while ( isFull){
wait();
}
doPut(v);
notifyAll();
}
public synchronized V take() throws InterruptedException{
while( isEmpty()){
wait();
}
V v = doTake();
notifyAll();
retur v;
}
}
四、锁
synchronized
在Java语言中有两种内建的synchronized语法:synchronized语句、synchronized方法。
synchronized语句:当源代码被编译成字节码的时候,会在同步块的入口位置和出口分别插入monitorenter和monitorexit字节码指令。
synchronized方法:在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位设置为1。
每个对象都有一个锁,也就是监视器(monitor)。当monitor被占有时表示它被锁定。线程执行monitorenter指令时尝试获取对象所对应的monitor的所有权。
相同点:Lock能完成synchronized的相同功能。
synchronized | java.util.concurrent.locks.Lock | |
---|---|---|
原理 | 在对象头设置标记 | Lock接口的实现类只用volatile修饰的int变量保证每个线程都拥有对该int的可见性和原子修改。 |
自动释放锁 | 必需手工在finally从句中释放。 | |
可 定时,中断、公平锁、非阻塞 |
ReentrantLock
ReentrantLock利用CAS+CLH队列来实现。支持公平锁和非公平锁。
CAS:Compare and Swap,比较并交换。CLH队列:带头结点的双向非循环链表。
ReentrantLock的实现:先通过CAS尝试获取锁。如果锁已经被占据,就加入CLH队列并被挂起。当锁被释放后,排在CLH队首的线程会被唤醒,然后CAS再次尝试获取锁。在这个时候,非公平锁
:如果同时还有另一个线程进来尝试获取,那么有可能会让这个线程抢先获取。公平锁
:如果同时还有另一个线程进来尝试获取,当它发现自己不是在队首的话,就会排到队尾,由队首的线程获取到锁。
Condition
Condition和Lock关联使用,就像条件队列和内置锁相关联一样。在相关联的Lock上调用Lock.newCondition()创建Condition。
Condition比内置条件队列提供更丰富的功能:在每个锁上可存在多个等待,条件等待是可中断的或者不可中断的、基于时限的,以及公平的或非公平的队列操作。
与内置条件队列不同的是,对于每个Lock,可以有任意数量的Condition对象。Condition对象继承了相关的Lock对象的公平性,对于公平的锁,线程会依照FIFO顺序从Condition.await中释放。
注意:在Condition对象中,与wait,notify和notifyAll方法对应的分别是await,signal,signalAll。但是,Condition继承了Object,因而它也包含wait和notify方法。一定要确保使用的版本——await和signal。
锁的高级特性
可重入:线程的可重入,是指外层函数获得锁之后,内层也可以获得锁。ReentrantLock和synchronized都是可重入锁。
惊群效应(Herd Effect):占有锁的线程释放后,所有等待获取锁的竞争者同时被唤醒,都尝试抢占锁。
公平锁和非公平锁:非公平锁普遍比公平锁开销小。但如果必须要锁的竞争者按顺序获得锁,那么就需要实现公平锁。
阻塞锁和自旋锁:阻塞锁会有上下文切换,如果并发量比较高且临界区的操作耗时比较短,那么造成的性能开销就比较大。如果临界区操作耗时比较长,一直保持自旋,也会对CPU造成更大的负荷。
锁的对象
实例同步方法,锁是当前实例对象
。
静态同步方法,锁是当前对象的Class对象
。
同步方法块,锁是synchonized括号里的对象
。
死锁
死锁是指多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。必须满足以下四个条件才发生死锁:
-
互斥
:一个资源每次只能被一个线程使用。 -
请求与保持
:一个线程因请求资源而阻塞时,不释放已获得的资源。 -
不剥夺
:线程已获得的资源,在末使用完之前,不能被强行剥夺。 -
循环等待
:若干线程之间形成循环等待资源关系。
避免死锁最简单的方法就是阻止循环等待条件,将系统中所有的资源设置标志位并排序,规定所有的进程申请资源必须按顺序进行。
避免死锁的通用经验法则是:当要访问共享资源A、B、C 时,保证每个线程都按同样的顺序访问共享资源。
活锁
处于活锁的线程的状态是不断改变的,活锁可以认为是一种特殊的饥饿。一个现实的活锁例子是两个人在走廊碰到,两个人都试着避让对方好让彼此通过,但是因为避让的方向都一样导致最后谁都不能前进。
饥饿
锁降级
锁降级是指写锁降级成读锁。如果当前线程拥有写锁,然后将其释放,最后获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,最后释放(先前拥有的)写锁的过程。
锁降级中的读锁是否有必要呢?答案是必要。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
读写锁ReentrantReadWriteLock
读写锁有两个锁,一个是读操作相关的锁,称为共享锁;另一个是写操作相关的锁,也叫排它锁。多个读锁之间不互斥,读锁与写锁互斥,写锁与写锁互斥。读写锁是用来提升并发程序性能的锁分离
技术的成果。
悲观锁
悲观锁假设最坏的情况,并且只有在确保其他线程不会干扰(通过获取正确的锁)的情况下才能执行下去。
常见实现如独占锁。
安全性高,但在中低并发程度下的效率低。
乐观锁
乐观锁借助冲突检查机制来判断在更新过程中是否存在其他线程的干扰,如果存在,这个操作将失败,并且可以重试(也可以不重试)。
常见实现如CAS等。
部分乐观锁削弱了一致性,但提高了中低并发程度下的效率。
五、java线程间通信之条件队列
条件队列中存储的是"处于等待状态的线程",这些线程在等待某种条件变成真。正如每个Java对象都可以作为一个锁,每个对象同样可以作为一个条件队列,这个对象的wait,notify,notifgAll就构成了内部条件队列的API。对象的内置锁与条件队列是相互关联的。要调用条件队列的任何一个方法,必须先持有该对对象上的锁。
"条件队列中的线程一定是执行不下去了才处于等待状态",这个"执行不下去的条件"叫做"条件谓词"。
wait()方法的返回并不一定意味着正在等待的条件谓词变成真了。
举个列子:假设现在有三个线程在等待同一个条件谓词变成真,然后另外一个线程调用了notifyAll()方法。此时,只能有一个线程离开条件队列,另外两个线程将仍然需要处于等待状态,这就是在代码中使用while(conditioin is not true){this.wait();}而不使用if(condition id not true){this.wait();}的原因。
另外一种情况是:同一个条件队列与多个条件谓词互相关联。这个时候,当调用此条件队列的notifyAll()方法时,某些条件谓词根本就不会变成真。
在本文的例子中,可以看到使用while而不是if来判断条件谓词是否为空,就是基于以上几种原因的考虑。用一句话来概括:“每当线程被从wait中唤醒时,都必须再次测试条件谓词”,切记,下面是条件等待的标准形式:
void xxxMethod() throws InterruptedException{
synchronized(lock){
while(!conditionPredition)
lock.wait();
doSomething();
}
}
六、线程
终止线程的三种方法
- 使用
volatile 布尔变量
作为退出标志,使线程正常退出,也就是当run方法完成后线程终止。 - 使用stop方法强行终止线程,不推荐此方法,因为stop和suspend及resume都是作废过期的方法,使用它们可能造成数据状态不一致。
- 使用
interrupt方法
中断线程。(推荐
)
线程状态
- 新建状态: 用new语句创建的线程对象处于新建状态,此时它和其它的java对象一样,仅在堆中被分配了内存。
- 就绪状态(New): 创建了线程后,调用它的start()方法,该线程就进入就绪状态。处于这个状态的线程位于可运行池中,等待获得CPU的使用权。
- 运行状态(Runnable): 处于这个状态的线程占用CPU,执行程序的代码。
- 阻塞状态(Blocked): 当线程处于阻塞状态时,java虚拟机不会给线程分配CPU,直到线程重新进入就绪状态,它才有机会转到运行状态。
阻塞状态分为三种情况
1) 位于对象等待池
中的阻塞状态: 当线程运行时,如果执行了某个对象的wait()方法,JVM就会把这个线程放到这个对象的等待池中。
2) 位于对象锁
中的阻塞状态: 当线程运行时,试图获得某个对象的同步锁时,如果该对象的同步锁已经被其他的线程占用,JVM就会把这个线程放到这个对象的琐池中。
3) 其它的阻塞状态: 当前线程执行了sleep()方法,或者调用了其它线程的join()方法,或者发出了I/O请求时,就会进入这个状态中。
Waiting, Time_waiting
5. 死亡状态(Terminated): 当线程退出run()方法,就进入死亡状态,该线程结束了生命周期。要么正常退出、要么遇到异常退出。
如果希望明确地让一个线程给另外一个线程运行的机会,可以采取以下的办法
1、调整线程的优先级。
2、让处于运行状态的线程调用Thread.sleep()方法,或者调用Thread.yield()方法,或者调用另一个线程的join()方法。
线程中断
线程中断是重要的线程协作机制。如果在循环体中,出现类似wait()或者sleep()的操作,则只能通过中断来识别。
竞态条件
当多个线程同时修改同一数据对象时,可能会产生不正确的结果,这时候就存在一个竞争条件(race condition)。
如果首先要执行的程序竞争失败排到了后面执行,那么整个程序就会出现不确定的bugs。这种bugs很难发现而且会重复出现,因为线程间是随机竞争。
线程通信
Java.lang.Object类中提供两个用于线程通信的方法
1、wait(): 线程释放对象的锁,JVM会把该线程放到对象的等待池中。该线程等待其它线程唤醒。
2、notify(): 执行该方法的线程唤醒在对象的等待池中等待的一个线程,JVM从对象的等待池中随机选择一个线程,把它转到对象的锁池中。
线程睡眠:当线程在运行中执行了sleep()方法时,会放弃CPU,转到阻塞状态,不会释放它所持有的锁。
等待其它线程的结束:调用另一个线程的join()方法,当前运行的线程将转到阻塞状态,直到另一个线程运行结束,它才恢复运行。
join与synchronized的区别是:join在内部使用wait()方法进行等待,而synchronized关键字使用的是“对象监视器”做为同步。
多线程使用建议
- 起个有意义的名字
方便debug或追踪。 - 避免锁定,缩小同步的范围
锁花费的代价高昂且上下文切换耗费时间空间,试试最低限度的使用同步和锁,缩小临界区。因此相对于同步方法更喜欢同步块,拥有对锁的绝对控制权。 - 多用同步类,少用wait 和 notify
CountDownLatch, Semaphore, CyclicBarrier 和 Exchanger 这些同步类简化了编码操作,而用wait和notify很难实现对复杂控制流的控制。 - 多用并发集合,少用同步集合
并发集合比同步集合的可扩展性更好。
参考
Java核心技术点之多线程
JAVA并发-条件队列