一、线程与锁——第一天
线程与锁模型其实是对底层硬件运行过程的形式化,这种形式化既是该模型最大的优点,也是它最大的缺点。我们借助Java语言来学习线程与锁模型,不过内容也适用于其他语言。
1、知识点
线程与锁模型会带来三个主要的危害:竞态条件、死锁和内存可见性,本节提供了一些避免这些危害的准则:
- 对共享变量的所有访问都需要同步化;(竞态条件)
- 读线程和写线程都需要同步化;(内存可见性)
- 按照约定的全局顺序来获取多把锁;(死锁)
- 当持有锁时尽量避免调用外星方法;(死锁)
- 应该尽可能缩短持有锁的时间;(死锁)
2、自习
- William Pugh的网站:Java内存模型
- [http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html](JSR 133(Java内存模型)FAQ)
- 深入理解Java内存模型-程晓明,这个系列的文章值得仔细研读
- Java内存模型是如何保证对象初始化时线程安全的?是否必须通过加锁才能在线程之间安全地公开对象?
(1)JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
(2)新的 JMM 还寻求提供一种新的 初始化安全性 保证——只要对象是正确构造的(意即不会在构造函数完成之前发布对这个对象的引用,换句话说,不要让其他线程在其他地方能够看见一个构造期间的对象引用),然后所有线程都会看到在构造函数中设置的 final 字段的值,不管是否使用同步在线程之间传递这个引用。而且,所有可以通过正确构造的对象的 final 字段可及的变量,如用一个 final 字段引用的对象的 final 字段,也保证对其他线程是可见的。这意味着如果 final 字段包含,比如说对一个 LinkedList 的引用,除了引用的正确的值对于其他线程是可见的外,这个 LinkedList 在构造时的内容在不同步的情况下,对于其他线程也是可见的。
(3)在讲了如上的这段之后,如果在一个线程构造了一个不可变对象之后(对象仅包含final字段),你希望保证这个对象被其他线程正确的查看,你仍然需要使用同步才行。 - 了解反模式“双重检查锁模式”(double-checked locking)以及为什么称之为反模式。
(1)程晓明的这篇文章——双重检查锁定与延迟初始化讲得十分清楚,关键在于:指令重排序导致在多线程情况下,其他线程可能访问到未初始化的对象。
(2)解决方案有二:用volatile修饰instance对象;采用Initialization On Demand Holder idiom方案,即基于类的初始化方案(关键是JVM在初始化类的时候需要获取一把锁)。
(3)选择方法:如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。
二、线程与锁——第二天
内置锁虽然方便、灵活,但是也有很多限制:
- 一个线程因为等待内置锁而进入阻塞后,就无法中断该线程了;
- 尝试获取内置锁时,无法设置超时;
- 获得内置锁,必须使用synchronized块;
Java 5之前,常常使用ReentrantLock锁代替synchronized关键字,因为ReentranLock锁可中断、可设置获取锁的超时时间、可实现细粒度加锁(链表上的交替锁)、可使用条件变量。
ReentrantLock的使用模式如下:
Lock lock = new ReentrantLock();
lock.lock();
try {
《使用共享资源》
} finally {
lock.unlock();
}
并发编程有时需要等待某个事件发生,条件变量就是为这种情况而生的。使用条件变量的模式是:
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
lock.lock();
try {
while(!《条件为真》) {
condition.await();
}
《使用共享资源》
} finally {
lock.unlock();
}
1、知识点
ReentrantLock和java.util.concurrent.atomic突破了使用内置锁的限制,利用新的工具我们可以做到:
- 在线程持有锁的时候中断它;
- 设置线程获取锁的超时时间;
- 按照任意顺序获取和释放锁;
- 用条件变量等待某个条件为真;
- 使用原子变量避免使用锁。
2、自习
- ReentrantLock创建时可以设置一个描述公平性的变量。什么是“公平”的锁?何时适合使用公平锁?使用非公平的锁会怎样?
根据官方文档中的解释:
public ReentrantLock(boolean fair)
//Creates an instance of ReentrantLock with the given fairness policy.
//**Parameters:**
//fair - true if this lock should use a fair ordering policy
如果在绝对时间上,先对锁进行获取的请求一定被先满足,那么这个锁是公平的,也就是说等待时间最长的线程最有机会获取锁,也可以说锁的获取是有序的;反之,则是非公平锁。
公平锁的性能不如非公平锁——公平的获取锁没有考虑到操作系统对线程的调度因素,这样造成JVM对于等待中的线程调度次序和操作系统对线程的调度之间的不匹配;另一方面,公平锁可以防止“饥饿”情况的产生,在以TPS为唯一指标的场景下,可以考虑使用公平锁。
什么是ReentrantReadWriteLock?它与ReentrantLock有什么区别?适用于什么场景?
ReentrantReadWriteLock的中文名称是读写锁,在多线程场景中,如果没有写线程在操作模板对象,读写锁允许多个读线程同时读。当对于某个数据结构的操作主要是读操作而只有少量的写操作时,就非常适合使用ReentrantReadWriteLock。什么是“虚假唤醒”(spurious wakeup)?什么时候会发生虚假唤醒?为什么符合规范的代码不用担心虚假唤醒?
(1)线程有可能在没有调用过notify()和notifyAll()的情况下醒来;
(2)查看如下代码,doWait方法中发生了虚假唤醒——等待线程即使没有收到正确的信号,也能够执行后续的操作。
public class MyWaitNotify2{
MonitorObject myMonitorObject = new MonitorObject();
boolean wasSignalled = false;
public void doWait(){
synchronized(myMonitorObject){
if(!wasSignalled){
try{
myMonitorObject.wait();
} catch(InterruptedException e){...}
}
//clear signal and continue running.
wasSignalled = false;
}
}
public void doNotify(){
synchronized(myMonitorObject){
wasSignalled = true;
myMonitorObject.notify();
}
}
}
(3)为了防止假唤醒,保存信号的成员变量将在一个while循环里接受检查,而不是在if表达式里。这样的一个while循环叫做自旋锁(校注:这种做法要慎重,目前的JVM实现自旋会消耗CPU,如果长时间不调用doNotify方法,doWait方法会一直自旋,CPU会消耗太大)。被唤醒的线程会自旋直到自旋锁(while循环)里的条件变为false。以下MyWaitNotify2的修改版本展示了这点:
public class MyWaitNotify3{
MonitorObject myMonitorObject = new MonitorObject();
boolean wasSignalled = false;
public void doWait(){
synchronized(myMonitorObject){
while(!wasSignalled){
try{
myMonitorObject.wait();
} catch(InterruptedException e){...}
}
//clear signal and continue running.
wasSignalled = false;
}
}
public void doNotify(){
synchronized(myMonitorObject){
wasSignalled = true;
myMonitorObject.notify();
}
}
}
- 什么是AtomicIntegerFieldUpdater?它与AtomicInteger有什么区别?适用于什么场景?
(1)AtomicIntegerFieldUpdater用于保证已经new出来的实例的原子性,AtomicInteger用于构造具备原子性的Integer实例。
(2)使用第三方库的时候,如果需要给第三方库提供的对象增加原子性,则使用AtomicIntegerFieldUpdater。
三、线程与锁——第三天
java.util.concurrent包不仅提供了第二天介绍的比内置锁更好的锁,还提供了一些通用高效、bug少的并发数据结构和工具。在实际使用中,较之自己实现解决方案,我们应更多地使用这些现成的工具。
1、知识点
- 使用线程池,而不是直接创建线程
//线程池的大小设置为可用处理器数的2倍
int threadPoolSize = Runtime.getRuntime().availableProcessors() * 2;
ExecutorService executor = Executors.newFixedThreadPool(threadPoolSize);
while(true) {
Socket socket = server.accept();
executor.execute(new ConnectionHandler(socket));
}
- 使用CopyOnWriteArrayList让监听器相关的代码更简单高效;
- 使用ArrayBlockingQueue让生产者和消费者之间高效协作;
- ConcurrentHashMap提供了更好的并发访问。