1. 背景
本文讨论下锁。
锁的总结2.知识
锁 在计算机中 是指并发控制的机制。
像 乐观锁,悲观锁,互斥锁 等也都是 并发控制的机制,或者说是资源争用控制的机制。
2.1 乐观锁和悲观锁
概述
- 乐观锁 : 实际上是“自上次取用后没变化则提交,否则回滚”的策略
- 悲观锁 : 实际上是“先取锁再访问”的保守策略
乐观锁 ( Optimistic Concurrency )
乐观锁,即乐观并发控制 ( Optimistic Concurrency Control ): 是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生 “锁” 的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务上次读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。
适用场景
乐观并发控制多数用于数据争抢不激烈
、冲突较少的环境中,这种环境中,偶尔回滚事务的成本会低于读取数据时锁定数据的成本,因此可以获得比其他并发控制方法更高的吞吐量。
悲观锁 ( Pessimistic Concurrency )
“悲观锁”,即 悲观并发控制 ( Pessimistic Concurrency Control)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作读某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。
适用场景
悲观并发控制主要用于数据争用激烈的环境
,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。
2.2 互斥锁 ( Mutual exclusion )
互斥锁( Mutual exclusion,缩写 Mutex)是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区域(critical section)达成。
临界区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。一个程序、进程、线程可以拥有多个临界区域。
互斥锁的需求:
- 若没有任何线程处于临界区域时,任何要求进入临界区域的线程必须立刻得到允许。
- 任何时间只允许一个线程在临界区域运行。
- 线程只能在临界区域内停留一有限的时间。
- 在临界区域停止运行的线程,不准影响其他线程运行。
互斥锁的 实现有:信号标,重入锁,监视器等多种实现。
2.3 可重入互斥锁 (reentrant mutex)
可重入互斥锁( reentrant mutex )是互斥锁的一种,同一线程对其多次加锁不会产生死锁。
可重入互斥锁也称递归互斥锁(recursive mutex)。
普通互斥锁有不可重入的问题:如果函数先持有锁,然后执行回调,但回调的内容是调用它自己,就会产生死锁。互斥锁 解决了不可重入的问题。
如果对已经上锁的普通互斥锁再次进行“加锁”
操作,其结果要么失败,要么会阻塞至解锁。而如果换作可重入互斥锁,当且仅当尝试加锁的线程(就是已持有该锁的线程)时,它再次加锁操作就会成功。
可重入互斥锁一般都会记录被加锁的次数,只有执行相同次数的解锁操作才会真正解锁。
2.4 读写锁 ( ReadWriteLock )
读写锁是计算机程序的并发控制的一种同步机制,也称“共享-互斥锁”、多读者-单写者锁。读操作可并发重入,写操作是互斥的。
读写锁内部实现需要两把互斥锁。
2.5 自旋锁
自旋锁是计算机科学用于多线程同步的一种锁,线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。
适用场景
自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源。 synchronized 会导致 抢不到锁 的线程进入阻塞状态,是比较重的。JVM 从 1.5 开始,引入了 轻量锁和偏向锁,默认启用了自旋锁,他们都属于乐观锁。
2.6 公平锁
公平锁就是保障了多线程下各线程获取锁的顺序,先到的线程优先获取锁,而非公平锁则无法提供这个保障。
某个线程尝试获取锁时,先会尝试 CAS ,失败后会把自己放入 这个是锁的等待队列,这时队列里多个等待锁的顺序如果是有序的,就是公平锁,如果无序则是非公平锁。
Java 中的 ReentrantLock 构造函数可以默认的锁策略是非公平锁。
2.7 本章总结
分类 | 简要说明 | 适用场景 | 获得优势 | Java 实现 |
---|---|---|---|---|
乐观锁 | 自上次取用后没变化则提交,否则回滚 | 数据争抢不激烈 | 吞吐量 | AtomicReference 系列 |
悲观锁 | 先取锁再访问 | 数据争抢激烈 | 数据安全的保证 | synchronized 关键字 |
互斥锁 | 任何时间只允许一个线程在临界区域运行 | 数据安全的保证 | ||
可重入互斥锁 | reentrant mutex 同一线程对其多次加锁不会产生死锁。它是互斥锁的一种。 | 避免死锁 | reentrant mutex) | |
读写锁 | 其实是两把互斥锁。读操作可并发重入,写操作是互斥的。 | 读写分离控制 | ReadWriteLock | |
自旋锁 | 反复检查锁变量是否可用,多多等待一会。避免线程阻塞和再唤醒的调度开销。 | 对于线程只会阻塞很短时间的场合 | 避免额外的调度开销 |
3. Java 中锁的实现
下面讨论在 java 中锁机制的实现。
3.1. synchronized 同步
synchronized 是 Java 的关键字,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。
synchronized 应用在方法上的示例
示例:
public synchronized void synHello(){
...//方法体
}
怎么判断 synchronized 互斥
可以简单理解为锁住对象对应的指针地址,只要区分好指针对象是否同一个地址,就可以判断两个线程的锁是否互斥。
修饰对象 | 被锁住对象 | 是否全局唯一 | 简单例子 |
---|---|---|---|
类的类型 | 类的类型 | √ |
synchronized(Example.class) {...} , 全局锁,其他线程无法进入 |
静态成员函数 | 类的类型 | √ |
public static synchronized void f() {...} 锁住类的类型 |
类的实例 | 类的实例 | × |
synchronized(this){...} 或者 final A a = new A()synchronized(a){...}
|
普通成员函数 | 类的实例 | × |
public synchronized void f() {...} 锁住类的实例 |
可重入
synchronized 是可重入的,意思就是当前线程获得锁之后,其他线程就无法获得锁进入,但是当前线程自己还可以再次获得锁多次进入。
synchronized 同步锁的四种状态
从 JDK 1.6 之前:synchronized 是重量级锁,效率低下。
从 JDK 1.6 之后:synchronized 做了很多优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
synchronized 同步锁一共包含四种状态:
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
它会随着竞争情况逐渐升级。synchronized 同步锁可以升级但是不可以降级,目的是为了提高获取锁和释放锁的效率。
无锁
无锁状态,无锁即没有对资源进行锁定
偏向锁
大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
轻量级锁
轻量级锁是指当前锁是偏向锁的时候,被另外的线程所访问,那么偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
重量级锁
重量级锁也就是通常说 synchronized 的对象锁
锁状态的升级
在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们;
偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁;
如果线程争用激烈,那么应该禁用偏向锁。
3.2 ReentrantLock ( 可重入锁 )
ReentrantLock ,名字翻译过来为可重入锁。它的可重入性表现在同一个线程可以多次获得锁,而不同线程依然不可多次获得锁。
ReentrantLock 分为公平锁和非公平锁:
- 公平锁保证等待时间最长的线程将优先获得锁
- 而非公平锁并不会保证多个线程获得锁的顺序
但是非公平锁的并发性能表现更好,ReentrantLock默认使用非公平锁。
代码示例:
ReentrantLock reentrantLock = new ReentrantLock(); // 默认 "不公平锁"
boolean isFair = true; // 是否 公平锁
ReentrantLock reentrantLock2 = new ReentrantLock(isFair); // 这个构造方法可以产出 "公平锁"
3.3 读写锁 ( ReadWriteLock )
Java 里的 ReadWriteLock 是读写锁,可以对共享变量的读写提供并发支持,它有两个方法分别返回一个读锁和一个写锁。
接口原型如下:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
3.4 AtomicReference 系列
AtomicReference 系列有 AtomicInteger,AtomicBoolean,AtomicLong,AtomicReference 等, 是基于 CAS 实现的乐观锁。
主要特性的实现是 compareAndSet 方法。
方法原型:
public final boolean compareAndSet(V expect, V update) {
return unsafe.compareAndSwapObject(this, valueOffset, expect, update);
}
3.3 了解更多
锁的接口 Lock。ReentrantLock 实现了这个接口。
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
3. 锁的优化
使用 CAS
如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,使用volatiled+ CAS操作会是非常高效的选择。因为加锁会导致线程的上下文切换,上下文切换的耗时比同步操作本身更耗时。
减少锁的时间
不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内
减少锁的粒度
它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。
比如:ConcurrentHashMap 使用一个Segment 数组。一次仅仅锁一个段落。数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定。
锁粗化
要看场景:
假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;
使用读写锁
ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;
读写分离
CopyOnWrite 容器 是一种读写分离的思想,读和写 使用不同的容器。 添加元素的时候不直接往当前容器添加,而是先将复制出一个新的容器,在新的容器里添加元素。添加完元素之后,再将原容器的引用指向新的容器。这样的好处是可以并发的读而不需要加锁,因为当前容器不会添加任何元素。
可选的有: CopyOnWriteArrayList 、CopyOnWriteArraySet
4. 扩展
CAS ( 比较与交换 )
CAS 的全称是 Compare And Swap(比较与交换),它是一种无锁算法。
CAS操作中包含三个操作数:
- 原值:内存里已经存储了的原有的值 (V)
- 进行比较的 预期原值(A)和 将要写入的新值(B)。
在写入(更新)新的值时,要 携带 “预期原值”和新值,并进行判断:
如果 "真实原值(V) " 与 "预期原值A 相同",则认为合法,写入新值替换真实原值,否则什么也不做
类似“版本号”的概念,新写入的值要是“覆盖原先那个版本的”,如果原先版本被其他的人改变了不在是“ 预期的那个版本 ” 了,则失败。
当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
java.util.concurrent.atomic 包下提供了几个默认实现:AtomicInteger,AtomicBoolean,AtomicLong,AtomicReference 等等。本文末有个示例。
AQS
AQS ( AbstractQueuedSynchronizer ) AQS 是基于 CAS的。 ReentrantLock、ReentrantReadWriteLock 都是基于 AQS 的实现。
就是继承AbstractQueuedSynchronizer类,然后使用它提供的方法来实现自己的锁。
ReentrantLock的Sync也是通过这个方法来实现锁的
Condition
Condition 用于替代传统的 Object 的 wait()、notify() 实现线程间的协作。
在 Condition 对象中,与 wait、notify、notifyAll 方法对应的分别是 await、signal 和 signalAll。
Condition 必须要配合 Lock 一起使用,一个 Condition 的实例必须与一个 Lock 绑定。
并发工具类
常见的有: Semaphore、CountDownLatch、CyclicBarrier
-
Semaphore 可以指定多个线程同时访问某个资源,而 synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源。由于 Semaphore 适用于限制访问某些资源的线程数目,因此可以使用它来做限流。
-
CountDownLatch 是一个倒计数器,它允许一个或多个线程等待其他线程完成操作。因此,CountDownLatch 是共享锁。CountDownLatch 的 countDown() 方法将计数器减1,await() 方法会阻塞当前线程直到计数器变为0
5. 示例
CAS 示例: compareAndSet 对比旧值才写入成功
看代码就明白了:
class Demo1 {
private AtomicInteger atomicInteger = new AtomicInteger(1);
public void setValue(int oldVal, int newVal) {
atomicInteger.compareAndSet(oldVal, newVal);
}
public int getValue() {
return atomicInteger.get();
}
}
class Main {
public static void main(String[] args) {
Demo1 demo1 = new Demo1();
demo1.setValue(100, 222); // 失败,预期是 100 而实际却是 1
System.out.println("#1 修改后" + demo1.getValue());
demo1.setValue(1, 2222); // 成功
System.out.println("#2 修改后" + demo1.getValue());
}
}
6.参考:
https://zh.wikipedia.org/wiki/%E4%B9%90%E8%A7%82%E5%B9%B6%E5%8F%91%E6%8E%A7%E5%88%B6
https://zh.wikipedia.org/wiki/%E8%AF%BB%E5%86%99%E9%94%81
https://zh.wikipedia.org/wiki/%E5%8F%AF%E9%87%8D%E5%85%A5%E4%BA%92%E6%96%A5%E9%94%81
https://blog.csdn.net/qq_14828239/article/details/81975487
https://zhuanlan.zhihu.com/p/56512421
网友评论