第四章 锁的优化及注意事项
4.1 有助于提高“锁”性能的几点建议
4.1.1 减小锁持有时间
有助于降低锁冲突的可能性,进而提升系统的并发能力
4.1.2 减小锁粒度
- 典型应用场景ConcurrentHashMap;其内部进一步细分了若干个小的HashMap,称之为段,默认情况下一个ConcurrentHashMap被细分为16个段(SEGMENT)。
- 添加时,根据hashCode得到该表项应该存放在哪个段中,然后对该段加锁,并完成put操作。由于默认16段,因此幸运的话,则同时可以接受16个线程同时插入。
- 减少锁粒度会引入一个问题:当系统需要取得全局琐时,其消耗的资源会比较多。如ConcurrentHashMap的size方法,需要先获得所有段的锁,再求和,再释放锁,导致性能差于HashMap。(实际中ConcurrentHashMap会先尝试使用无锁方式求和,如果失败,才会尝试这种加锁方法)
- 减少锁粒度,就是指缩小锁定对象的范围,从而减少锁冲突的可能性,进而提高系统的并发能力。
4.1.3 读写分离锁来替换独占锁
- 减少锁粒度时通过分割数据结构,而读写分离锁,则是系统功能点的分割。适合读多写少的场景。
4.1.4 锁分离
- 是读写锁思想的进一步延伸。典型案例LinkedBlockingQueue的实现。由于基于链表,因此take和put两个操作分别作用于队列的前端和尾端。从理论上讲,两者并不冲突。
4.1.5 锁粗化
- 虚拟机在遇到一连串连续地对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这个操作就叫做锁粗化。
- 锁粗化和减少锁持有时间时相反的,但在不同场合,它们的效果不一样。
4.2 Java虚拟机对锁优化所做的努力
4.2.1 锁偏向
- 针对加锁操作的优化手段。核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式。当这个线程再次请求琐时,无须再做任何同步操作。节省了大量的锁申请时间。
- 适合于几乎没有锁竞争的场合。如果竞争激烈,其效果不佳。
- 通过-XX:+UseBiasedLocking设置是否开启偏向锁。
4.2.2 轻量级锁
- 如果偏向锁失败,虚拟机不会立即挂起线程,会使用轻量级锁的优化手段。只是简单的将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果成功,则可以顺利进入临界区。如果失败,则表示其他线程抢先争夺到了锁,当前线程则会膨胀为重量级锁。
4.2.3 自旋锁
- 锁膨胀后,虚拟机为了避免线程真实地在操作系统层面挂起,虚拟机会做最后的努力——自旋锁。系统会进行一次赌注:它会假设不久的将来,线程可以得到锁。因此虚拟机让当前线程执行几个空循环,如果若干次循环后顺利得到锁,则进入临界区,如果失败,才会真实的在操作系统层面进行挂起。
4.2.4 锁消除
- 一种更彻底的锁优化。虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。节省毫无意义的请求锁时间。
- 锁消除设计的一项关键技术为逃逸分析。就是观察某一个变量是否会逃出某一个作用域。
- 逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析,使用-XX:+EliminateLocks参数打开锁消除
4.3 人手一支笔:ThreadLocal
4.3.1 ThreadLocal的简单使用
- 为每一个线程分配不同的对象,需要在应用层面保证,ThreadLocal只是起到了简单的容器作用。EX:参考148
4.3.2 ThreadLocal的实现原理
- ThreadLocalMap,在set时,先获取当前对象,然后获取线程的ThreadLocalMap,并将值放入其中,key为当前的ThreadLoca对象,value就是我们需要的值,其可以理解为一个Map,但其实不是Map,它是定义在Thread内部的成员。
get也是根据当前的线程从ThreadLocalMap中获取数据,也就是变量的维护都在改类里,也意味着只要线程不退出,对象的引用将一直存在。线程退出时,Thread类也会进行一些清理,包括清理ThreadLocalMap(也就是将持有的引用都变为null)
如果使用线程池,则意味着线程并不会退出,这样将一些大对象设置到ThreadLocal中,可能会使得系统内存泄漏,即设置了对象到ThreadLocal中,但是不清理它,使用多次后,这个对象其实已经不再使用,但是它却无法被回收。因此,如果希望及时回收对象,最好使用ThreadLocal.remove方法将这个变量移除。
ThreadLocalMap的实现使用了弱引用,弱引用比强引用弱得多。虚拟机在回收垃圾时,发现时弱引用回立即回收,ThreadLocalMap内部由一系列Entry构成,每一个都是WeakReference。其K就是ThreadLocal实例,作为弱引用使用。因此实际上,它并不持有ThreadLocal的引用,当ThreadLocal被外部强回收(如设置为null)时,ThreadLocalMap中的key就回变为null。垃圾回收时,就会将这些垃圾数据回收。
4.3.3 对性能有何帮助
- 如果共享对象对于竞争的处理容易引起性能损耗,应该考虑使用ThreadLocal为每个线程分配单独的对象。典型案例就是对线程下产生随机数。
4.4 无锁
- 锁是一种悲观策略;无锁是一种乐观策略,无锁策略使用的是比较交换的技术即CAS。
4.4.1 与众不同的并发策略:比较交换(CAS)
- CAS会使得程序看起来更加复杂,但其非阻塞性,对死锁免疫,并且,线程间的相互影响远比基于锁的方式要小,也没有锁竞争带来的系统开销,线程间频繁调度带来的开销,因此性能更好。
- CAS算法过程:
- 包含三个参数CAS(V,E,N)。V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后CAS返回当前值V的真实值。CAS操作持着乐观态度,认为自己可以完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会更新成功,其余均失败,失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,也允许失败的线程放弃操作。
4.4.2 无锁的线程安全整数:AtomicInteger
- 与Integer不同,其是可变的,并且是线程安全的。
内部实现上,保存一个核心字段:private volatile int value,代表当前实际取值。另一个private static final long valueOffset,保存value在AtomicInteger对象中的偏移量,偏移量是实现AtomicInteger的关键。
内部使用了Unsafe类实现cas
4.4.3 Java中的指针:Unsafe类
- 其封装了一些类似指针的操作,指针如果在计算偏移量出错,可能会导致覆盖别人的内存,导致系统崩溃。
方法:
- public final native boolean compareAndSwapInt(Object o,long offset,int expected,int x)
- 第一个参数o表示给定的对象,offset为对象内的偏移量,即一个字段到对象头部的偏移量,通过这个偏移量可以快速定位字段。expected表示期望值,x表示要设置的值,如果指定字段等于expected,那么就设置为x。
JDK并不希望开发人员使用Unsafe类,获取Unsafe实例需要通过其提供的工厂方法getUnsafe,但是其会检查调用getUnsafe方法的类,如果这个类的ClassLoader不为null,则抛出异常,拒绝工作,因此应用程序无法直接使用Unsafe类,它是JDK内部使用的专属类,由Bootstrap类加载器加载。
4.4.4 无锁的对象引用:AtomicReference
- 与AtomicInteger区别时,其是对普通对象的引用。
- 原子操作逻辑上的不足:
- 当获取对象当前值,并准备修改为新值时,该对象被其他线程连续修改多次,经过多次修改,使得对象的值又回到了旧值,这样当前线程就无法判断这个对象是否被修改过。
- 因此当如果我们是否能修改对象的值,不仅取决于当前值,还和对象的过程变化有关,则此时AtomicReference就无能为力了。此时需要使用AtomicStampedRefrence。EX:参考163
4.4.5 带有时间戳的对象引用:AtomicStampedReference
- AtomicReference因为丢失了状态信息,导致了无法应对需要考虑变化过程的业务。
- AtomicStampedReference内部不仅维护了对象值,还维护了一个时间戳(实际上它可以使任何一个整数来表示状态值);设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功。
- 方法:
- 参数:期望值 新值 期望时间戳 新时间戳
- public boolean compareAndSet(V expectedReference,V newReference,int exceptedStamp,int newStamp)
4.4.6 数组也能无锁:AtomicIntegerArray
包含AtomicLongArray、AtomicReferenceArray,表示long型数组和普通对象数组
4.4.7 让普通变量也享受原子操作:AtomicIntegerFieldUpdater
- 包含AtomicLongFieldUpdater、AtomicReferenceFieldUpdater,表示long型、普通对象进行cas修改。
- 注意事项:
- Updater只能修改它可见范围内的变量。因为Updater使用反射得到这个变量。如果变量不可见,就会出错。如private可见范围
- 为了确保变量被正确的读取,它必须是volatile类型的。
- 由于CAS操作会通过对象实例中的偏移量直接进行赋值,因此,不支持static字段。
4.4.8 挑战无锁算法:无锁的Vector实现
- EX:参考171
4.4.9 让线程之间互相帮助:细看SynchronizedQueue的实现
- 大量使用了无锁工具。
- 因为容积为0,因此生产者将数据放入时,如果没有消费者,数据本身和线程对象都会打包在队列中等待。
- 将put和take抽象为一个共通的Transferer.transfer(),是实现SynchronousQueue的核心。
- Object transfer(Object e,boolean timed,long nanos);
- 参数e非空时,表示当前操作传递给一个消费者,如果为空,则表示当前操作需要请求一个数据。timed参数决定了是否存在timeout时间,nanos决定了timeout时间长。如果返回值非空,则表示数据已经接受或者正常提供,如果为空,表示失败,超时或者中断。
- transfer函数实现三步骤:
- 如果等待队列为空,或者队列中节点的类型和本次操作是一致的,那么将当前操作压入队列等待。如等待队列中是读线程等待,本次操纵也是读,因此两个读都需要等待,进入等待队列的线程可能会被挂起,它们会等待一个匹配操作。
- 如果等待队列中的元素和本次操作是互补的,那么就插入一个完成状态节点,并且让他匹配到一个等待节点上。接着弹出这两个节点,并且使得对应的两个线程继续执行。
- 如果线程发现等待队列的节点就是完成节点,那么帮助这个节点完成任务。其流程和步骤2是一致的。EX:参考177
- 参与工作的所有线程不仅仅是竞争资源关系,它们之间彼此还会互相帮助。在一个线程内部,可能会帮助其他线程完成它们的工作。这种模式可以更大程度减少饥饿的可能,提高系统整体的并行度。
4.5 有关死锁的问题
- 实际环境中,通常的表现就是相关线程不工作,并且CPU占用率为0,因为死锁的线程不占用CPU。
- 死锁检测:jps命令得到java进程的ID,再通过jstack命令得到线程的堆栈。