数据结构 | 底层实现 | 线程安全 | 性能 | 支持null键值 |
---|---|---|---|---|
HashTable | 基于哈希表 | 是 | put/get/remove-o(1) | 不支持 |
HashMap | 基于哈希表 | 否 | put/get/remove-o(1) | 支持 |
TreeMap | 基于红黑树 | 否 | put/get/remove-o(log n) | 不支持null键,支持null值 |
ConcurrentHashMap | 锁分段技术 | 是 | 并发环境下优于同步版本的集合 | 不支持 |
JDK的IO框架运用了装饰者模式(特征:一系列的类以相同的抽象类或者接口作为其构造函数的入参)。如:InputStream <- FilterInputStream <- BufferedInputStream。
GUI、Swing等的组件事件监听,运用了观察者模式。
新版JDK中HTTP/2 Client API,创建HttpRequest,运用了构建器模式,通常被实现成fluent风格的API,也叫方法链。
线程安全是一个多线程环境下正确性的概念,即要维持多线程环境下共享的、可修改的状态的正确性。因此可以通过封装状态,或者让状态不可变(final, immutable)来保证状态的线程安全。
线程安全需要保证几个基本特性:
为了保证锁的正确释放,每一个lock()方法后最好接一个try-catch-finally块:
lockObj.lock();
try {
// do something
...
} finally {
lockObj.unlock();
}
lock()方法最好不要放在try块中,以免lock()时发生异常,导致锁无故被释放。
###四、条件变量(java.util.concurrent.Condition)
如果说ReentrantLock是synchronized的替代选择,那么Condition则是将wait、notify、notifyAll等晦涩难懂的操作转化为直观可控的对象行为。
条件变量最典型的应用就是在标准类库的ArrayBlockingQueue等中。
首先在构造函数中通过ReentrantLock对象的newCondition方法将条件变量创建出来:
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
两个条件变量是从同一个再入锁对象中创建出来的,然后再将它们应用于特定的操作中,如下面的put操作,会一直等待直到notFull条件满足:
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
那么notFull什么时候会满足呢?当然是有元素出队的时候:
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
final Object[] items = this.items;
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
notFull.signal();
return x;
}
可以看到,通过调用条件变量的singal()方法,通知该条件变量的条件已满足,从而等待线程可以继续之后的行为。signal和await方法要成对调用,不然如果只有await方法,线程会一直等待直到被中断(interrupt)。
synchronized代码块是由一对monitorenter/monitorexit指令实现的,Monitor对象是同步的基本实现单元。
在 Java 6 之前,Monitor的实现完全是靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。
现代JDK中,JVM对此进行了很大的改进,提供了三种不同的Monitor实现,即常说的三种不同的锁:偏斜锁(Biased Locking),轻量级锁和重量级锁,大大改进了其性能。
所谓锁的升级、降级,就是JVM优化synchronized运行的机制,当JVM检测到不同的竞争状况时,会自动在这三种锁之间切换。
当没有竞争出现时,会默认使用偏斜锁。JVM会使用CAS操作,在对象头上的Mark Word部分设置线程ID,以表示这个对象向该线程倾斜,所以这里不涉及真正的互斥锁。这样做的假设是基于很多场景中,大部分对象生命周期中最多只被一个线程锁定,使用偏斜锁就降低了无竞争状态下的开销。
当另一个线程试图去锁定已经被偏斜的对象,JVM会撤销掉偏斜锁,切换到轻量级锁。轻量锁依赖CAS操作Mark Word来试图获取锁,如果重试成功,就使用普通的轻量级锁,否则将进一步升级到重量级锁。
Java并发包中的各种同步工具,不仅仅是各种Lock,其它的如Semphore、CountDownLatch,甚至是早期的FutureTask等,都是基于AQS框架实现的。
先看一下类图:
ReadWriteLock是一个单独的接口,它代表了一对锁,分别对应只读和写操作。
StampedLock是一个单独的类型,它不支持可再入的语义,即它不是以持有锁的线程为单位。
为什么会需要读写锁等其它类型的锁呢?因为synchronized和ReentrantLock都太过“霸道”,要么不占,要么独占。在写操作不多,只有大量并发读操作的环境下,这些锁的效率会比较低。
下面是一个基于ReadWriteLock实现的Map数据结构,当数据量大,并发读多、并发写少时,比同步版本更具优势:
public class ReadWriteLockSample {
private final Map<String, String> map = new HashMap<String, String>();
private final ReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock readLock = rwl.readLock();
private final Lock writeLock = rwl.writeLock();
public String get(String key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
public void put(String key, String value) {
writeLock.lock();
try {
map.put(key, value);
} finally {
writeLock.unlock();
}
}
// ...
}
在运行中,如果写锁已经被某个线程锁定,则试图锁定读锁的操作不会成功,会等待写锁的释放。
读写锁看起来粒度更细一些,但在实际应用中,其表现也不尽如人意,主要是因为其相对较高的开销。
所以JDK在后期引入了StampedLock,在提供类似读写锁的同时,还支持优化读模式,该模式基于一种假设,即大部分的读操作都不会与写操作冲突,其逻辑是先试着读,然后再通过validate方法确认当时是否进入了写模式,如果没有,则成功避免了开销;如果有,则重新尝试获取读锁。样例代码如下:
public class StampedLockSample {
private final StampedLock sl = new StampedLock();
void mutate() {
long stamp = sl.writeLock();
try {
write();
} finally {
sl.unlockWrite(stamp);
}
}
Data access() {
long stamp = sl.tryOptimisticRead();
Data data = read();
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
data = read();
} finally {
sl.unlockRead(stamp);
}
}
return data;
}
// ...
}
注意这里的writeLock和unlockWrite一定要保证成对调用。
思考:自旋锁是什么,适合什么场景呢?
是低并发,且同步代码耗时较短时的一种乐观的优化。
第二次调用start方法时,会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。
Java 5 之后,线程的状态被定义在其公共内部枚举类java.lang.Thread.State中,分别是:
public final native void wait(long timeout) throws InterruptedException;
从操作系统的角度看,线程是系统调度的最小单元,作为任务的真正执行者,有自己的栈(Stack)、寄存器(Register)、本地存储(Thread Local)等,但是会和进程内其它线程共享文件描述符、虚拟地址空间等。
在具体实现中,线程还分为内核线程、用户线程,Java线程的实现和虚拟机相关,基本上在 Java 1.2 之后,JDK已经抛弃了所谓的Green Thread,也就是用户调试的线程,现在的模型是一对一映射到操作系统内核线程。
看Thread的源码,可以发现很多操作都是以JNI形式调用的本地代码。
private native void start0();
private native void setPriority0(int newPriority);
private native void interrupt0();
这种实现有利有弊,总体来说,Java得益于精细粒度的线程和相关的并发操作,其构建高拓展性的的大型应用的能力毋庸置疑,但是它的复杂性也提高了并发编程的门槛。近几年的Go语言提供了协程(coroutine),大大提高了构建并发应用的效率。于此同时,Java也在Loom项目中,孕育新的类似轻量级用户线程(Fiber)等机制,将来的新版JDK中也许就会使用到它。
使用线程可以扩展Thread类,然后实例化。但更常见的做法是实现一个Runnable,将逻辑放在这个Runnable中,通过它构建Thread并启动。这样做的好处是,不会受Java不支持多继承的限制,重用代码实现,当需要重复执行相同的代码时优势明显。而且,它也能更好地与现代Java库中的Executor框架相结合,这样我们不需要操心线程的创建和管理,也能利用Future等机制更好地处理执行结果。线程生命周期通常和业务之间没有本质联系,混淆实现需求和业务需求,就会降低开发的效率。
下图是线程状态和方法之间的关系图:
Thread和Object的方法,听起来简单,实际应用中被证明非常晦涩、易错,这也是为什么Java后来引入了并发包的原因。有了并发包,大多数情况下,我们都不需要直接去调用wait/notify之类的方法了。
Thread daemonThread = new Thread();
daemonThread.setDaemon(true);
daemonThread.start();
// 推荐
while (isCondition()) {
waitForCondition(...);
}
// 不推荐,可能引入bug
if (isCondition()) {
waitForCondition(...);
}
死锁是一种特定的程序状态。在多个实体之间,由于循环依赖,导致彼此一直处于等待之中,没有个体能够继续前进。死锁不光发生在线程之间,存在资源独占的进程之间也可能发生死锁。
定位死锁最常见的方法就是利用jstack等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往jstack就能直接发现问题所在,类似JConsole甚至可以在图形界面进行有限的死锁检测。
用jstack检测死锁的步骤为,首先通过jps或系统的ps命令、任务管理器等找到程序的进程ID,其实通过jstack pid命令获取线程栈,最后结合代码分析线程栈信息,找出死锁。如果是简单的死锁,jstack可以直接替我们找出:
上图明显告诉了我们存在死锁,以及死锁的成因。
如果程序运行过程中发生了死锁,往往是无法在线解决的。只能通过重启、修改程序来解决问题。所以代码开发阶段互相审查,或者利用工具进行预防性排查,也是很重要的。
死锁发生的四个必要条件为:
可以破坏上述条件之一,破坏掉任意一个即可解除死锁。互斥和不可剥夺的条件在并发环境下可能不太好破坏,毕竟要保证线程安全。
下面是几种预防死锁的方法:
关于今天我们讨论的题目你做到心中有数了吗?今天的思考题是,有时候并不是阻塞导致的死锁,只是某个线程进入了死循环,导致其他线程一直等待,这种问题如何诊断呢?
这种情况可以认为是自旋锁死锁的一种,其它线程因为得不到具体的信号提示,导致线程一直饥饿。这种情况下可以查看线程CPU的使用情况,排查出使用CPU时间片最多的线程,再找出该线程的堆栈信息,排查代码。
基于互斥量的锁如果发生死锁,往往CPU的使用率较低,实践中也可以从这方面进行排查。
我们通常说的Java并发包就是java.util.concurrent及其子包,包含了java的各种基础并发工具类,具体包括以下几个方面:
我们进行多线程编程,无非是达到这样几个目的:
首先可参考下面的类图:
总体上类的结构比较简单。如果我们侧重于Map放入或获取的速度,而不在乎顺序,那么应该选ConcurrentHashMap,否则选ConcurrentSkipListMap;如果我们要对大量数据进行频繁的修改,那么ConcurrentSkipListMap也可能表现出优势。
为什么并发容器里没有ConcurrentTreeMap呢?因为要红黑树在插入、删除结点时,都要移动树的节点从而达到平衡,这导致在多线程场景下很难进行合适粒度的同步,所以很难实现高效的线程安全。
而SkipListMap结构则简单很多,通过层次结构提高访问速度,虽然空间不够紧凑(O(nlogn)),但是在增删元素时线程安全的开销要小很多。下面是它的结构示意图:
关于两个CopyOnWrite容器,其实CopyOnWriteArraySet是包装了CopyOnWriteArrayList来实现的,所以在学习时可以专注其中一种。
CopyOnWrite的意思是,任何修改操作,如add, remove, set,都会导致数组的复制,对复制的数组进行修改后,再直接替换掉原来的数组,通过这种防御性的方式,来实现另类的线程安全。所以这种数据结构,还是适合读多写少的场景,不然修改的开销是比较明显的。
Java并发包中的容器,从命名上看可大致分为三类:Concurrent*, CopyOnWrite*和Blocking,同样是线程安全容器,它们的区别为:
废话不多说,先上图,图中没有将非线程安全队列包括进来:
Deque类型的侧重点是对队列头尾都支持插入、删除操作。
大部分类型实现了BlockingQueue接口,意思就是在插入时如果有必要会等待直到队列不满,获取时同样会等待直到队列非空。
另一个BlockingQueue常被考察的点是队列是否有界,这一点也往往会影响我们在应用开发时的选择,简单总结如下:
public ArrayBlockingQueue(int capacity, boolean fair)
如果我们分析不同队列的底层实现,BlockingQueue的内部基本都是基于锁实现,下面是典型的LinkedBlockingQueue:
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
可以看出,和之前介绍过的ArrayBlockingQueue不同的是,LinkedBlockingQueue内部的两个条件变量,是从两个不同的ReentrantLock中构建出来的,粒度更细,所以在通用场景下,LinkedBlockingQueue的吞吐量要大于Array的。
下面的take方法与ArrayBlockingQueue也不一样,因为是链表结构,它要自己维护队列的元素数量值:
public E take() throws InterruptedException {
final E x;
final int c;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await();
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
而类似ConcurrentLinkedQueue,则是基于CAS的无锁技术,不需要在每个操作时使用锁,所以扩展性表现要更加优异。
SynchronousQueue,在 Java 6 中,其实现方式发生了很大的变化,由CAS操作代替了之前基于锁的逻辑,是Executors.newCachedThreadPool()默认使用的队列。
以LinkedBlockingQueue、ArrayBlockingQueue和SynchronousQueue为例,需求可以从多个方面来考虑: