有两种机制防止代码块受并发访问的干扰。synchronized 关键字,ReentrantLock 类。
1 可重入锁(ReentrantLock)
可重入锁(ReentrantLock)并不是一种替代内置加锁的方法,而是当内置加锁机制不适用时,作为一种可选择的高级功能。
1.1 属性
- ReentrantLock 实现了 Lock 接口,并提供了与 synchronized 相同的互斥性、内存可见性和可重入的加锁语义。
- 与 synchronized 相比,ReentrantLock 提供了更好的活跃性或性能。但也更“危险”,不会自动清除锁,必须在 finally 中释放锁。
- ReentrantLock 是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个
持有计数
来跟踪对 lock 方法的嵌套调用。线程在每一次调用 lock 都要调用 unlock 来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。
1.2 实现方法
重入的一种实现方法:
- 为锁关联一个“获取计数值”和锁当前的“持有者线程”, 当计数值为 0 时, 这个锁被认为没有被任何线程持有。
- 当线程请求一个未被持有的锁时, JVM 将记录锁的持有者线程, 并将获取计数值置为 1
- 如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时, 计数值会相应地递减
- 当计数值为 0 时, 这个锁将释放, JVM 将取消记录锁的持有者线程。
1.3 构造方法
Lock myLock = new Reentrantlock();
myLock.lock(); // a Reentrantlock object
try {
// critical section
} finally {
myLock.unlock(); // make sure the lock is unlocked even if an exception is thrown
}
- ReentrantLock():构建一个可以被用来保护临界区的可重入锁。
- ReentrantLock(boolean fair):构建一个带有公平策略的锁。
- 公平锁:按照发出请求的顺序来获得锁。这一公平的保证将大大降低性能。
- 非公平锁(默认):允许插队
2 条件对象(Condition)
一个锁对象可以有一个或多个相关的条件对象(Condition)。
条件对象管理那些已经获得了一个锁但是却不能做有用工作的线程。
2.1 获取条件对象
newCondition 获得一个条件对象。
Condition condition = myLock.newCondition();
2.2 阻塞
线程条件 await(),线程进入该条件的等待集,阻塞。
condition.await();
2.3 恢复
直到某个其他线程调用同一条件上的 signalAll() 重新激活因为这一条件而等待的所有线程。
otherCondition.singalAll();
此时,线程应该再次测试该条件。
while (!okToProceed())
condition.await();
另一个方法 signal(),则是随机解除等待集中某个线程的阻塞状态。
如果没有其他线程再次调用 signal,那么系统就死锁了。
3 内置锁(synchronized)
总结一下有关锁和条件的关键之处:
- 锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。
- 锁可以管理试图进入被保护代码段的线程。
- 锁可以拥有一个或多个相关的条件对象。
- 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。
Lock 和 Condition 接口为程序设计人员提供了高度的锁定控制。然而, 大多数情况下, 并不需要那样的控制。
3.1 对象内部锁
从 1.0 版开始, Java中的每一个对象都有一个内部锁。
如果一个方法用 synchronized 关键字声明,那么对象的锁将保护整个方法。
1)synchronized 方法以方法所在对象为锁
// 以下三种方法等效
public synchronized void method() {
// method body
}
public void method() {
synchronizedd (this) {
// method body
}
}
public void methoda() {
this.intrinsicLock.lock();
try {
// method body
} finally {
this.intrinsicLock.unlock();
}
}
2)静态 synchronized 方法以 Class 对象为锁
// 以下两种方法等效
public static synchronizedd void method() {
}
public static void method() {
// 以当前对象的Class对象作为锁
synchronizedd (SyncObject.class) {
}
}
每个 Java 对象都可以用做一个实现同步的锁, 这些锁被称为内置锁或监视器锁。
3.2 同步建议
内部对象锁只有一个相关条件,其 wait 与 notifyAll ,等价于 Condition 的 await 与 signalAll
在使用 wait/ notifyAll 之前, 应该考虑使用同步器。
内部锁和条件存在一些局限。包括:
- 不能中断一个正在试图获得锁的线程。
- 试图获得锁时不能设定超时。
- 每个锁仅有单一的条件,可能是不够的。
Lock 和 Condition 对象还是同步方法?
- 最好既不使用 Lock/Condition 也不使用 synchronized 关键字。可使用 java.util.concurrent 包中的一种机制,或并行流。
- 如果特别需要 Lock/Condition 结构提供的独有特性(可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结、构的锁)时,才使用 Lock/Condition,否则,应该优先使用synchronized。
3.3 同步阻塞
线程可通过获得对象的的内部锁,进入同步阻塞。
lock 对象被创建仅仅是用来使用每个 Java 对象持有的锁。
private Object lock = new Object();
synchronized (obj){
// critical section
}
3.4 客户端锁定
以下例子依赖于 Vector 类对自己的所有可修改方法都使用内部锁。
public void transfer(Vector<Double> accounts, int from, int to, int amount){
synchronized (accounts){
accounts.setCfron, accounts.get(from) - amount):
accounts.set(to, accounts.get(to) + amount);
}
System.out.println(. . .);
}
客户端锁定是非常脆弱的,通常不推荐使用。
3.5 监视器概念
监视器可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性。具有如下特性:
- 监视器是只包含私有域的类。
- 每个监视器类的对象有一个相关的锁。
- 使用该锁对所有的方法进行加锁。
- 该锁可以有任意多个相关条件。
Java 以 synchronized 关键字不是很精确地采用了监视器概念,使得线程的安全性下降:
- 域不要求必须是 private。
- 方法不要求必须是 synchronized。
- 内部锁对客户是可用的。
Java 监视器模式
- 遵循
Java监视器模式
的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。 Java监视器模式
仅仅是一种编写代码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。//通过一个私有锁来保护状态 public class PrivateLock{ private final Object myLock = new Object(); Widget widget; void someMethod(){ synchronized(myLock){ //访问或修改Widget的状态 } } }
- 使用私有锁对象的好处:
- 私有的锁对象可以将锁封装起来,使客户端代码无法得到锁,但客户端代码可以通过方法来访问锁,以便(正确或不正确)参与到它的同步策略中
- 如果客户端代码错误地获取到另一个对象的锁,那么可能产生
活跃性问题
4 读-写锁(ReadWriteLock)
一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。在这种情况下就可以使用读-写锁。
ReadWriteLock 中的读取锁和写入锁只是读-写锁对象的不同视图。
- ReentrantReadWriteLock 为读写锁提供了可重入的加锁语义
- ReentrantReadWriteLock 在构造时可以选择非公平锁或者公平锁
- 写线程可以降为读线程,读线程不能升级为写线程
- 如果由读线程持有锁,而另一个线程请求写入锁,其他线程都不能获取读取锁
//1) 构造一个ReentrantReadWriteLock对象
private ReentrantReadWriteLockrwl = new ReentrantReadWriteLock();
//2) 抽取读锁和写锁
private Lock readLock = rwl.readLock();
private Lock writeLocfc = rwl.writeLock();
//3) 对所有的访问者加读锁
public double getTotalBalance() {
readLock.lock();
try {
} finally {
readLock.unlock();
}
}
//4) 对所有的修改者加写锁
public void transfer() {
writeLocfc.lock();
try {
} finally {
writeLocfc.unlock();
}
}
5 死锁
如果有一组进程或线程,其中每个都在等待一个只有其它进程或线程才可以执行的操作,那么就称它们被死锁了。
5.1 死锁的必要条件
- 互斥:一个资源每次只能被一个线程使用
- 占有并等待:一个进程因请求资源而阻塞时, 对已获得的资源保持不放
- 非抢占:线程已获得的资源, 在未使用完之前, 不能强行被剥夺
- 循环等待:若干进程之间形成一种头尾相接的循环等待资源关系
对于 Java 中的 synchronized 来说, 前三个条件是天然满足且无法打破的, 所以只要防止循环等待条件发生, 就可以避免 synchronized 造成的死锁
5.2 避免死锁
要避免死锁,应该确保在获取多个锁时,在所有的线程中都以相同的顺序获取锁。
- 以相同顺序获取锁
- 可通过 System.identityHashCode 方法,获取参数的 hash 值,根据 hash 值来决定锁顺序。若 hash 相同则先获取加时锁来避免死锁
- 如果对象内存在唯一的,不可变的, 并且具有可比性的键值, 可以通过比较键值来决定获取锁的顺序, 也避免了引入加时赛锁的复杂。
- 使获取多个锁的集合尽量小
- 开放调用:尽可能地使用开放调用(在调用某个方法时不需要持有锁)
- 定时锁:使用定时锁与轮询锁(Lock 类 tryLock 方法)。试图申请一个锁,成功返回 true,否则立即返回 false,避免阻塞
- 使用可中断的锁获取操作:lockInterruptibly 方法或 tryLock 方法
5.3 其它活跃性危险
1)饥饿
当线程无法访问它所需要的资源而不能继续执行时,就发生了饥饿。
- 引发饥饿的最常见资源就是 CPU 时钟周期。
- Java 应用程序中线程优先级使用不当
- 在持有锁时执行一些无法结束的结构
- 避免使用线程优先级,这会增加平台依赖性,并可能导致活跃性问题。
2)丢失信号
不良的锁管理可能会导致糟糕的响应性。 如果某个线程长时间持有一个锁, 其它获取这个锁的线程就必须等待很长时间。
3)活锁
活锁:线程不断重复执行相同的操作, 而且总会失败。
在重试机制中引入随机性可解决活锁问题。
6 锁优化代码
影响锁竞争的两个因素:锁的请求频率,持有锁的时间。
6.1 缩小锁的范围(“快进快出”)
尽可能缩短锁的持有时间,将与锁无关的代码移除同步代码块
- 缩小同步代码块能提高可伸缩性
- 需要采用原子方式执行的操作必须包含在一个同步块中
- 当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。
6.2 减小锁的粒度
降低线程请求锁的频率,从而减小发生竞争的可能性。
通过锁分解和锁分段等技术,减小锁操作的粒度,能实现更高的可伸缩性,最终降低每个锁被请求的频率。
然而,使用的锁越多,那么发生死锁的风险也就越高。
Amdahl 定律:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中并行组件与串行组件所占的比重。
1)锁分解
将一个锁分解为两个锁。将一个锁中的两个不相干对象拆分出来,使对两个对象的操作分别获取各自的锁
2)锁分段
将一个锁分解为多个锁。在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,ConcurrentHashMap。
锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。
6.3 避免热点区域
当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引人一些 “热点域(Hot Field) ”,而这些热点域往往会限制可伸缩性。
例如 HashMap 里的 size 使用了共享的计数器,每次增删操作都会更新计数器,这个热点域会限制可伸缩性。
为了避免这个问题,ConcurrentHashMap 中的 size 将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。
6.4 替代独占锁
第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
6.5 监测 CPU 的利用率
如果 CPU 没有得到充分利用,通常有以下几种原因:
负载不充足、I/O 密集、外部限制、锁竞争。
6.6 向对象池说“不"
通常,对象分配操作的开销比同步的开销更低。
7 JVM 锁优化
7.1 自旋锁与自适应自旋
- 自旋锁:让后面请求锁的那个线程等待,执行一个忙循环
- 在 JDK 1.6 已经默认开启自旋锁;如果锁被占用的时间很短自旋等待的效果就会非常好,反之则会白白消耗处理器资源;
- 在 JDK 1.6 中引入了“自适应的自旋锁”,这意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定;
7.2 锁消除
- 消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除;
- 锁消除的主要判断依据来源于逃逸分析的数据支持;
7.3 锁粗化
- 原则上总是推荐将同步块的作用范围限制得尽量小 – 只有在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁;
- 但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗;
7.4 轻量级锁
轻量级锁是 JDK 1.6 之中加入的新型锁机制,并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,下使用 CAS 操作去消除同步使用的互斥量,减少传统的重量级锁使用操作系统互斥量产生的性能消耗;
在线程栈帧中 CAS 获取锁,如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁
7.5 偏向锁
- 偏向锁也是 JDK 1.6 中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能;偏向锁就是“在无竞争的情况下把整个同步都消除掉”,连 CAS 操作都不做了;
- 偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步;
- 当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束,根据锁对象目前是否被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定的状态,后续的同步操作就如上面介绍的轻量级锁那样执行;偏向锁、轻量级锁的状态转化以及对象 Mark Work 的关系如下图所示:
偏向锁可以提高带有同步但无竞争的程序性能,它同样是一个带有效益权衡性质的优化;