目录
CountDownLatch 和 CyclicBarrier
协作问题(同步)
协作问题:
当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。
一个线程执行完了一个任务,如何通知执行后续任务的线程开工;
解决协作问题主要使用:管程;
管程
管程的作用
管程用于管理共享变量以及对共享变量的操作过程,让他们支持并发。
Java语言中,管程管理类的成员变量和成员方法,让这个类是线程安全的。
并发编程领域的三大核心问题:分工,协作,资源分配;
管程可以解决协作问题,资源分配(互斥)问题;
分工问题主要由多线程和线程池来解决;详见:分工章节;
管程解决问题的思路
Java管程通过锁(synchronized,Lock )和条件变量(Condition )实现。
管程通过锁解决资源分配(互斥)问题,通过条件变量(Condition )解决协作问题。
管程解决资源分配(互斥)问题 - 互斥锁
管程通过互斥锁,保护临界区代码中的重要资源,将共享变量及其对共享变量的操作统一封
装起来。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列
中等待,达到互斥的目的。
管程解决线程的协作问题 - 等待通知模式
等待通知模式是线程之间协作的常用模式;
还可以解决死锁问题,可以破坏死锁的占用且等待条件;
管程引入了条件变量的概念,每个条件变量都对应有一个等待队列;
条件变量的调用await()方法,让当前线程,释放锁资源,进入条件变量对应的等待队列;
条件变量的调用signal()方法,让条件变量对应的等待队列中的线程被唤醒,可以去尝试获取
锁资源,运行代码;
条件变量的await和signal方法,实现的等待通知模式,可以让多个线程之间能根据条件状态
相互协作;
Synchronized使用条件变量让多线程协作的方法是:wait,notify,notifyAll;Synchronized只
包含一个条件变量;
并发包中Lock的条件变量包含的等待通知方法是:await,signal,signalAll方法;Lock可以包
含多个条件变量;
示例代码:
final Lock lock = new ReentrantLock();
final Condition conditionA = lock.newCondition(); // 条件变量A
final Condition conditionB = lock.newCondition(); // 条件变量B
funA(){
...
lock.lock();
// conditionA对应的条件不满足,释放锁资源,让当前线程进入队列等待;
while (conditionA对应的条件不满足){
conditionA.await();
}
...
}
fun(){
...
lock.lock();
// conditionA对应的条件满足,唤醒conditionA等待队列中的线程,告诉他们此刻conditionA对应的条件满足了;
if (conditionA对应的条件满足){
conditionA.signal();
}
...
}
Java中管程的实现方案
Java中实现管程的方式是Synchronized和并发包中的Lock;
1,Synchronized
synchronized是jdk自带的;
synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码;
synchronized仅支持一个条件变量;
synchronized 关键字可以用来修饰方法,也可以用来修饰代码块。
当修饰静态方法的时候,锁定的是当前类的 Class 对象;
当修饰非静态方法的时候,锁定的是当前实例对象 this;
饰代码块的时候,锁定了一个对象。
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
synchronized使用条件变量对应的协作方法:wait,notify,notifyAll;
notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线
程。notify() 的风险在于可能导致某些线程永远不会被通知到。
除非经过深思熟虑,否则尽量使用 notifyAll()。
wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁;所
以,wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部
调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:
java.lang.IllegalMonitorStateException。
2,Java并发包中的锁
锁实现的管程支持多个条件变量,需要开发人员自己进行加锁和解锁操作。
lock的底层实现是AQS;
AQS是AbstractQueuedSynchronizerr的简称,通过一个volatile修饰的int属性state代表同步状
态,例如0是无锁状态,1是上锁状态。多线程竞争资源时,通过CAS的方式来修改state,例如从0
修改为1,修改成功的线程即为资源竞争成功的线程,将其设为工作线程,资源竞争失败的线程会
被放入一个FIFO的队列中并挂起休眠,当工作线程释放资源后,会从队列中唤醒线程继续工作,
循环往复。
Happens-Before 支持synchronized的规则,却没有锁相关的规则,所以 Java SDK 里面锁保
证可见性的方法:利用了 volatile 相关的 Happens-Before 规则。详见 线程安全篇章Happens-
Before规则部分;
避免死锁,破坏不可抢占条件
解决死锁问题的方法:破坏下列条件中的一个即可;详见 线程安全篇章死锁部分
1,占有且等待:线程 T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源 X;
2,不可抢占:其他线程不能强行抢占线程 T1 占有的资源;
3,循环等待:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
破坏不可抢占条件的思路是:当占用部分资源的线程进一步申请其他资源时,如果申请不
到,可以主动释放它占有的资源,让其他线程可以申请使用。
synchronized是不支持破坏不可抢占条件的,并发包中的 Lock是支持的。
Lock 接口的三个方法,可以全面弥补 Synchronized 的问题:
void lockInterruptibly() //能够响应中断,阻塞时,可以被中断,释放已经获取的锁;
boolean tryLock(long time, TimeUnit unit) //支持超时,获取锁时,等待一定时间之后,就返回,而不是阻塞;
boolean tryLock() //非阻塞地获取锁,获取锁时,如果没有获取成功,直接返回,不阻塞;
可重入锁
所谓可重入锁,顾名思义,指的是线程可以重复获取同一把锁。
公平锁与非公平锁
一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤
醒一个等待的线程。唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不
提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
读写锁 - ReadWriteLock
ReadWriteLock针对读多写少这种并发场景,非常容易使用,并且性能很好。
读写锁,并不是 Java 语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守以下
三条基本原则:
允许多个线程同时读共享变量;
只允许一个线程写共享变量;
如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
读写锁特点:
只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛异常;
读写锁的升级与降级
升级:读锁还没有释放,获取写锁;不支持,可能导致写锁永久等待;
降级:释放写锁前,获取读锁;支持;
StampedLock
Java1.8版本里,提供了一种叫 StampedLock 的锁,它的性能就比读写锁还要好。
StampedLock 支持的三种锁模式。
写锁、悲观读锁和乐观读。
写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似,允许多个线程同时
获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。
ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻
塞;读锁释放了才能获取写锁;
StampedLock 提供的乐观读,允许一个线程获取写锁,也就是说不是所有的写操作都被阻
塞。乐观读这个操作是无锁的,所以相比较 ReadWriteLock 的读锁,乐观读的性能更好一些。如
果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。
StampedLock 支持锁的降级和升级;
锁的最佳实践
永远只在更新对象的成员变量时加锁
永远只在访问可变的成员变量时加锁
永远不在调用其他对象的方法时加锁
减少锁的持有时间、减小锁的粒度等业界广为人知的规则
锁的本质是在锁对象的对象头中,记录了锁的类型,和获取锁的线程id;
用锁的注意要点
锁,应是私有的、不可变的、不可重用的。
Integer、String、Boolean类型的对象不适合做锁;
1,锁不能变化,如果锁发生变化,就意味着失去了互斥功能。上述类型使用的是享元模式,
修改内容时,是生成了新的对象;
2,上述对象如果两个锁对象是同一个值,两个对象可能是同一个对象,所以看似是两把锁,
实际是一把锁;
JVM 开启逃逸分析之后,synchronized (new Object()) 这行代码在实际执行的时候会被优化掉。
逃逸分析
编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法
所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
逃逸:
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
无逃逸:
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
使用逃逸分析,编译器可以对代码做如下优化:
一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可
以不考虑同步。
二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远
不会逃逸,对象可能是栈分配的候选,而不是堆分配。
JDK1.8之后是默认开启逃逸分析的;
信号量 - Semaphore
init():设置计数器的初始值。
down():计数器的值减 1;如果此时计数器的值小于 0,则当前线程将被阻塞,否则当前线程
可以继续执行。
up():计数器的值加 1;如果此时计数器的值小于或者等于 0,则唤醒等待队列中的一个线
程,并将其从等待队列中移除。
init()、down() 和 up() 三个方法都是原子性的,并且这个原子性是由信号量模型的实现方保证
的。
Semaphore 可以允许多个线程访问一个临界区。
比较常见的需求就是我们工作中遇到的各种池化资源,例如连接池、对象池、线程池等等。
和管程相比,信号量可以实现的独特功能就是同时允许多个线程进入临界区,但是信号量不
能做的就是同时唤醒多个线程去争抢锁,只能唤醒一个阻塞中的线程,而且信号量模型是没有
Condition的概念的,即阻塞线程被醒了直接就运行了而不会去检查此时临界条件是否已经不满足
了。
static int count;
//初始化信号量
static final Semaphore s = new Semaphore(3);
static void func() {
//获取信号资源
s.acquire();
try {
//最多支持三个线程,操作count;
...
} finally {
//释放信号资源
s.release();
}
}
CountDownLatch 和 CyclicBarrier
CountDownLatch简要用法;
Executor executor = Executors.newFixedThreadPool(2);
// 计数器初始化为2
CountDownLatch latch = new CountDownLatch(2);
executor.execute(()-> {
...
func1();
latch.countDown();
});
executor.execute(()-> {
...
func2();
latch.countDown();
});
// 等待两个查询操作结束
latch.await();
func3();
线程池中的线程执行完func1()和func2();
在执行fun3();
CyclicBarrier简要用法;
// 执行回调的线程池
Executor executor = Executors.newFixedThreadPool(1);
final CyclicBarrier barrier = new CyclicBarrier(2, ()->{
executor.execute(()->func3());
});
void func(){
Thread T1 = new Thread(()->{
func1();
// 等待
barrier.await();
});
T1.start();
Thread T2 = new Thread(()->{
func3();
// 等待
barrier.await();
});
T2.start();
}
线程T1执行完func1,线程T2执行完func2,线程池中的线程会执行func3,执行回调函数的线
程是将 CyclicBarrier 内部计数器减到 0 的那个线程。
CountDownLatch 和 CyclicBarrier的比较
CountDownLatch 主要用来解决一个线程等待多个线程的场景,可以类比旅游团团长要等待
所有的游客到齐才能去下一个景点;
CyclicBarrier 是一组线程之间互相等待,像是几个驴友之间的相互等待;
CyclicBarrier 可以设置回调函数。
CountDownLatch 的计数器是不能循环利用的,也就是说一旦计数器减到 0,再有线程调用
await(),该线程会直接通过。
但 CyclicBarrier 的计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到 0
会自动重置到你设置的初始值。