基本概念
并发和并行:关于并发和并行的含义其实存在一些争议,但无非是定义不同。比较常见的说法,并发是指多个任务在单核 CPU 上分时执行,并行是指多个任务在多核 CPU 上同时执行。另一种说法,并发是指多个任务的执行在时间上重叠,在单核 CPU 上表现为分时执行,在多核 CPU 上表现为并行执行,并行是并发的子集。(当然还有其他说法,挺无聊的)另外,在单线程中也可以做到并发,例如 I/O 多路复用。
同步和异步:同步方法在方法执行完成之后才返回,异步方法可以在方法执行完成之前返回。异步方法只意味着非阻塞调用,并不一定是在另一个线程中执行。例如 C# 的 async 方法是在当前线程中执行,在遇到 await 时会将方法的剩余部分注册为延续(continuation),然后将控制权返回到方法的调用者,同时返回一个 Task 类型的对象,此时方法的调用者可以执行其他不依赖于方法结果的操作,从而避免阻塞。在 await 任务完成之后,延续会在线程池线程或者原始线程中执行。(参考 Task asynchronous programming model)
以上关于单线程中并发和异步的示例,在基于事件的系统中都有体现。事件循环在单个线程中接收和处理事件,通常使用 I/O 多路复用接收事件,使用异步 I/O 实现非阻塞。可以使用轮询判断异步 I/O 是否完成,更好的做法是使用 UNIX 信号在异步 I/O 完成时通知应用程序。如果要在异步 I/O 完成之后执行某些操作,通常会使用一种称为延续的数据结构,记录完成该事件需要的信息。(推荐阅读《OSTEP》第 33 章 基于事件的并发)
思考:什么是死锁?产生条件?如何预防(Prevention)、避免(Avoidance)和检测死锁?
死锁简单来说就是 ABBA 问题,即持有资源 A 的线程要获取资源 B,而持有资源 B 的线程也要获取资源 A。产生死锁的条件:互斥,持有并等待,非抢占,循环等待。死锁避免要求知道全局信息,根据信息来判断分配资源是否会发生死锁,例如银行家算法。死锁检测可以通过构建等待图,然后判断图中是否有环来实现。
死锁预防:① 消除循环等待:使用一致的加锁顺序(全序/偏序),例如根据锁的地址获取锁,或者根据对象的哈希值获取锁。② 消除持有并等待:在执行操作之前原子地获取所有锁,但是很难做到且会有性能问题。③ 消除非抢占:使用 tryLock + unlock 的方式获取锁,但是会有活锁问题(可以通过随机化等待时间来避免),以及需要回滚中间执行的操作。④ 消除互斥:使用非阻塞算法(利用 CAS 原子指令)。
并发包
同步器
AbstractQueuedSynchronizer(AQS)为实现依赖 FIFO 等待队列(以链表形式实现)的同步器提供框架。AQS 使用 int 类型的整数表示状态,使用获取(acquire)和释放(release)操作修改状态。状态以及获取和释放的含义由同步器决定:在 ReentrantLock 中表示重入次数;在 Semaphore 中表示剩余的许可数量;在 ReentrantReadWriteLock 中,高 16 位表示读锁计数,低 16 位表示写锁计数。AQS 内部使用 LockSupport 实现线程的阻塞和唤醒,内部使用类似信号量的许可机制(最多一个许可),即使在 unpark 之后调用 park 也不会导致阻塞。
相比 synchronized 关键字,ReentrantLock 提供可中断锁、定时锁和公平锁,以及支持等待多个条件,但是需要在 finally 中显示地执行解锁操作。公平锁和非公平锁的区别是能否插队,当有其他线程在队列中等待时,公平锁总是会将当前加锁线程入队,而非公平锁会尝试加锁,从而避免更多的上下文切换。
Semaphore 提供指定数量的许可,允许多个线程访问资源。ReentrantReadWriteLock 提供可重入的读写锁,允许在持有写锁的情况下获取读锁,从而实现锁降级(写锁降级为读锁),不支持锁升级(读锁升级为写锁),适用于读多写少的场景。不支持锁升级是因为,如果两个读线程同时进行升级(由于读锁是共享锁),则会发生死锁。
CountDownLatch 允许一个或多个线程等待,直到一组操作完成,计数不能被重置。CyclicBarrier 允许一组线程等待彼此到达屏障,当所有线程到达屏障之后,会执行设置的 barrierAction 动作,然后唤醒所有线程,重置计数器。以上提到的同步器只有 CyclicBarrier 基于 ReentrantLock 实现,其他都是直接基于 AQS 实现的。
线程池
创建线程的方式有三种:继承 Thread 类 + 重写 run 方法;实现 Runnable 接口;实现 Callable 接口,然后构造 FutureTask 对象,FutureTask 实现自 RunnableFuture 接口,所以也可以看作 Runnable 对象。
使用线程池的目的是复用线程,避免创建/销毁线程的开销,以及控制线程的数量。过多的线程会占用大量内存,而且不一定能提高系统的性能,因为 CPU 核心数有限(对于 CPU 密集型负载来说)。Executors 工具类提供创建线程池的工厂方法,可以创建固定/动态大小(ThreadPoolExecutor)、支持计划任务(ScheduledThreadPoolExecutor)、支持工作窃取和分解任务(ForkJoinPool)的线程池。
线程池通常会使用队列存放任务(Runnable 类型),队列可以分为同步队列、有界队列和无界队列。当线程池的线程数量达到设置的最大值,且队列已满(不是无界队列),则会执行拒绝策略。JDK 内置的拒绝策略有:抛出异常、丢弃当前任务、丢弃最早的任务、在当前线程执行该任务。
当线程池的线程数量超过 corePoolSize 时,空闲线程在超时之后被销毁。如果使用 ExecutorService 的 submit 提交任务,需要注意异常会被捕获到返回的 Future 对象中。如果没有任务,在 corePoolSize 范围内的线程会在获取任务时被阻塞队列阻塞。如果要终止线程,可以调用 shutdown 方法,然后可以调用 awaitTermination 等待。
并发容器
可以使用 Collections.synchronizedxxx()
方法获取线程安全的容器,但是获取的容器只是简单地对所有方法使用 synchronized 关键字来实现线程安全。CopyOnWriteArrayList 读操作不需要加锁,写操作加锁且不会修改原数组,而是执行写时复制(COW)。ConcurrentLinkedQueue 是非阻塞的无界队列,没有使用锁而是只用 CAS 实现线程安全。
ArrayBlockingQueue 是有界阻塞队列,使用单个的 ReentrantLock 实现线程安全,使用两个 Condition(notEmpty 和 notFull)实现阻塞等待,不过也可以调用非阻塞的方法(在队列满/空时直接返回 false/null)。LinkedBlockingQueue 是无界阻塞队列,使用两个 ReentrantLock(putLock 和 takeLock)实现线程安全,同样使用两个 Condition 实现阻塞等待。
可以使用 Unsafe 或者 VarHandle 实现 CAS 操作。AtomicInteger 使用 CAS 保证原子性,AtomicStampedReference 使用版本号解决 CAS 的 ABA 问题,AtomicIntegerArray 提供原子修改数组的方法,AtomicIntegerFieldUpdater 使用反射和 Unsafe 提供对 volatile 字段原子更新的方法。
如果要设计线程安全的哈希表,最简单的方式是使用 synchronized 关键字修饰所有方法,但是并发性很差。首先,可以想到减少锁的粒度,将单个独占锁分解为每个桶一个锁(结合 CAS 提高性能)。但是,计数操作会修改共享变量,如果使用独占锁将成为性能瓶颈。如果使用原子变量进行计数,在高并发下性能不会更好,由于缓存失效以及频繁的重试。解决方案依然是减少锁的粒度,可以使用类似 LongAdder 的做法。接下来可以思考扩容问题,多个线程辅助扩容可以加快速度,基本上 ConcurrentHashMap 就是这样设计的。
锁优化
思考:减少锁竞争的方式有哪些?
减小锁的范围,减小锁的粒度,读写锁/读写分离,线程私有。
ThreadLocal 的构造函数是空的,所以创建的对象没有和线程绑定,只有当调用 get/set 方法之后才会绑定。实际上,Thread 类有一个 ThreadLocalMap 类型的实例字段,set 方法会获取当前 ThreadLocal 对象的哈希值,使用 WeakReference 类型的数组存储 key/value(类似 WeakHashMap),key 就是 ThreadLocal 对象,value 就是 set 的参数。(该哈希表使用的是开放寻址法处理冲突)
通常所说的 ThreadLocal 存在内存泄露问题是指,当不再使用设置的 value 时(假设是很大的对象),如果存在该 ThreadLocal 的强引用,或者即使该 ThreadLocal 对象已经被回收,但是之后没有对 ThreadLocalMap 做操作,依然无法回收 value 对象。因为 ThreadLocalMap 的 Entry 对 value 有强引用,而只有在执行下一次 set/remove 操作时,该 Entry 以及 Entry 的 value 才会变为不可达的,所以最好在不使用时显式地执行 remove 操作。
思考:HotSpot 虚拟机对 synchronized 的优化有哪些?(详见《深入理解 Java 虚拟机》第 13 章)
自适应的自旋:动态调整自旋的时间,尽量避免重量级锁(互斥锁)的上下文切换开销,以及避免自旋过久占用 CPU 资源的开销。
锁消除:虚拟机在即时编译时,对不可能发生竞争的锁进行消除(依赖逃逸分析)。
锁粗化:如果连续地对相同对象加锁再解锁,会导致不必要的性能损耗,此时虚拟机会加大锁的范围(合并锁)。
轻量级锁:当目标对象没有被锁定,虚拟机会使用 CAS 获取对象的轻量级锁,目的是避免重量级锁的上下文切换开销。如果目标对象被轻量级锁定,那么自旋一段时间,超时之后升级为重量级锁。或者,如果有两个线程在等待获取轻量级锁,那么将锁升级为重量级锁。(轻量级锁本质上是复制对象的 Mark Word 到当前线程的栈帧中,然后使用 CAS 修改该对象的 Mark Word)
偏向锁:对象锁会偏向第一个加锁的线程,目的是减少无竞争情况下轻量级锁的 CAS 开销。即如果线程 A 对某个对象加锁(使用 CAS 修改 Mark Word),且该对象是第一次被加锁,那么线程 A 之后再次获取该对象锁时就不需要执行实际的加锁操作。但是,只要有其他线程尝试获取该对象的锁,那么偏向锁就会被撤销,变为未锁定或者轻量级锁定状态。(简单来说是这样,当然还有重偏向,以及如果调用过未重写的 Object::hashCode()
方法就不能偏向,之类的东西)
思考:轻量级锁和偏向锁优化什么情况下可以提升性能?什么情况下会降低性能?
轻量级锁和偏向锁都假设锁竞争发生的概率很小,如果真的发生(激烈的)锁竞争,那么轻量级锁和偏向锁很快就会升级为重量级锁,“优化”反而会额外增加轻量级锁 CAS 操作的开销以及撤销偏向的开销。
在 Java 15 中,偏向锁已弃用,详见 JEP 374: Deprecate and Disable Biased Locking。主要原因是偏向锁的实现代码复杂且具有侵入性,妨碍 HotSpot 虚拟机中同步系统设计的更改,以及在当前环境下偏向锁的适用范围有限。
内存区域
简单来说:程序计数器存储当前执行的字节码指令的地址;栈存储方法级别的数据,方法调用会创建栈帧,存储局部变量、返回地址等数据,有虚拟机栈和本地方法栈两种;堆存储对象数据;方法区也被称为类区,存储类级别的数据,包括类的字节码、静态变量和常量(在运行时常量池中)等数据。
思考:HotSpot 虚拟机使用 new 关键字创建对象的过程?对象在堆中的内存布局?
检查 new 指令的参数是否在运行时常量池中有对应的符号引用,然后检查该符号引用代表的类是否执行过类加载(区分类加载和加载,类加载包括加载、链接和初始化)。如果类加载完成,则为对象分配内存(使用 CAS + 重试保证原子性,或者使用线程私有内存),然后设置对象头。最后调用构造器执行初始化。
对象存储在堆中,由对象头、实例数据和对齐填充组成。对象头存储运行时数据(Mark Word)和指向类型元数据的指针。实例数据存储字段值,包括继承的字段,默认相同宽度的字段会被存放在一起,在该前提下父类字段会在子类之前。对齐填充的作用是内存对齐,HotSpot 虚拟机使用的是 8 字节对齐。
垃圾收集
基本概念
由于程序计数器、虚拟机栈和本地方法栈是线程私有的,占用的内存随方法返回或者线程结束而回收,所以不是垃圾收集器关注的重点。而堆和方法区是线程共享的,占用的内存何时回收是动态的,垃圾收集器会通过可达性分析判断什么对象可以回收。
最简单的想法是使用引用计数判断对象是否可以被回收,但是无法处理循环引用的问题。所以,通常是通过图的可达性分析来判断对象是否可以被回收,实际上就是维护一个可达的 GC Roots 的对象集,如果某个对象从 GC Roots 不可达,那么该对象就可以被回收。
引用类型:强引用、软引用、弱引用和虚引用。除强引用外的其他引用都对应一个继承自 Reference 抽象类的类,可以存储对其他对象的引用(称为 referent)。简单来说,强可达的对象不会被回收,软可达的对象会在内存溢出之前被回收,弱可达的对象会在下次垃圾收集时被回收,虚可达的对象已经是 finalized 的,不可达的对象可以被回收。(参考 java.lang.ref 文档)
思考:什么是 finalization 机制?finalize 方法为什么被弃用?
当对象不可达时,如果对象没有重写 finalize 方法,则对象可以直接被回收。如果对象已重写 finalize 方法且该方法没有被调用过,则对象是 finalizable 的。虚拟机的 Finalizer 线程会将该对象加入队列排队,等待调用其 finalize 方法,调用之后对象是 finalized 的。如果对象是 finalized 且依然不可达,那么对象就会被回收变为 reclaimed。
垃圾收集器至少需要两个周期才能回收 finalizable 对象,并且被该对象引用的所有不可达对象会被保留,直到该对象被回收。此外,JVM 不保证会调用所有 finalizable 对象的 finalize 方法。(推荐阅读 How to Handle Java Finalization’s Memory-Retention Issues)
finalization 机制存在问题,会导致性能问题、死锁和挂起。finalizer 的错误可能导致资源泄露;没有办法取消 finalization;不同对象的 finalize 方法调用之间没有指定的顺序。此外,无法保证 finalization 的完成时间。finalize 方法只能在 finalizable 对象上经过不确定的延迟之后调用。(参考 finalize 文档)
回收算法
分代收集理论:假设对象存活概率随对象年龄(经历垃圾收集的次数)的增加而增加,且跨代引用很少发生,则可以根据对象年龄,将堆划分成不同的区域(例如,新生代和老年代),然后使用不同的频率进行回收。相对于扫描所有对象而言,能够有效减少时间开销。
类加载机制
类的生命周期有 7 个阶段,各个阶段通常是交叉混合执行的,解析阶段可能在初始化之后开始。通常所说的类加载是指加载、连接和初始化。简单来说,加载阶段将类加载到方法区,验证阶段验证字节码是否符合规范,准备阶段执行静态字段的默认初始化(有例外情况),解析阶段将常量池的符号引用替换为直接引用。初始化阶段执行编译器生成的类构造器 <clint>()
方法(不是对象构造器),包括静态字段的显示初始化和静态初始化块。
类加载器用于实现加载阶段的“通过类的全限定名获取该类的二进制字节流”动作。在虚拟机中,类由类加载器和类的全限定名唯一确定。如果相同的类被不同类加载器加载,那么 instanceof 的判断结果就是 false。双亲委派机制是指,类加载器总是会将加载请求委派给父类加载器(父子类加载器是组合关系而不是继承关系),只有当父类加载器无法完成该加载请求时,子类加载器才会尝试加载,从而确保核心类(例如 Object)的唯一性。