3 锁

Wu Jun 2020-01-09 10:52:54
05 Java > 00 Java 基础 > 07 并发

有两种机制防止代码块受并发访问的干扰。synchronized 关键字,ReentrantLock 类。

1 可重入锁(ReentrantLock)

可重入锁(ReentrantLock)并不是一种替代内置加锁的方法,而是当内置加锁机制不适用时,作为一种可选择的高级功能。

1.1 属性

1.2 实现方法

重入的一种实现方法:

  1. 为锁关联一个“获取计数值”和锁当前的“持有者线程”, 当计数值为 0 时, 这个锁被认为没有被任何线程持有。
  2. 当线程请求一个未被持有的锁时, JVM 将记录锁的持有者线程, 并将获取计数值置为 1
  3. 如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时, 计数值会相应地递减
  4. 当计数值为 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
}

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 对象还是同步方法?

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 关键字不是很精确地采用了监视器概念,使得线程的安全性下降:

Java 监视器模式

4 读-写锁(ReadWriteLock)

一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。在这种情况下就可以使用读-写锁。

ReadWriteLock 中的读取锁和写入锁只是读-写锁对象的不同视图。

//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 避免死锁

要避免死锁,应该确保在获取多个锁时,在所有的线程中都以相同的顺序获取锁。

5.3 其它活跃性危险

1)饥饿

当线程无法访问它所需要的资源而不能继续执行时,就发生了饥饿。

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 自旋锁与自适应自旋

7.2 锁消除

7.3 锁粗化

7.4 轻量级锁

轻量级锁是 JDK 1.6 之中加入的新型锁机制,并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,下使用 CAS 操作去消除同步使用的互斥量,减少传统的重量级锁使用操作系统互斥量产生的性能消耗;

在线程栈帧中 CAS 获取锁,如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁

7.5 偏向锁

image

偏向锁可以提高带有同步但无竞争的程序性能,它同样是一个带有效益权衡性质的优化;