Java 的锁
Java 中的锁有三类,一种是关键字 Synchronized,一种是对象 lock,还有一种 volatile 关键字。
- Synchronized 用于代码块或方法中,他能是一段代码处于同步执行。
- lock 跟 synchronized 类似,但需要自行加锁和释放锁。必须要手动释放锁,不然会造成死锁。
- lock 比 synchronized 更有优势,因为他比 synchronized 多了嗅探锁定,多路分支通知,判断锁的状态等功能。
- 嗅探锁定:lock 可以使用 tryLock() 方法尝试获取锁,若获取不到就继续执行,不会造成线程的阻塞。而 synchronized 只能进入阻塞他。
- 多路分支通知:lock 可以创建多个 condition,然后可以将线程对应一个 condition,当要唤醒此线程时可以用对应的 condition 来唤醒。
- volatile 作用范围小,只作用在一个变量上。volatile 具有以下三个特性:
- 可见性:他会从工作内存中复制一份到主内存中,并且每次更新也会随之更新到主内存。当不同线程需要获取其值时,可以从主内存中获取,从而达到一致性。
- 原子性:这里的原子性是指在基本操作下(读,取)保持原子性。若在复合操作下(v++)则没有具备原子性。
- 禁止指令重排:添加了 volatile 关键字的变量,其前面的代码不能运行在此变量后,在后面的代码不能运行在此变量前。
- volatile 实际上并不是锁,不具备加锁,阻塞等操作。他使用的方式是根据 volatile 对象是否变化来判断接下来如何执行。
- volatile 也存在缺陷,有时在改变变量时可能还会取到先前的值,但这是非常小的小概率事件。
Java 的锁机制
1. 公平锁/非公平锁
公平锁就是获得锁的顺序按照先到先得的顺序。当一个线程或的锁并没有是否,接下来的线程就会进入阻塞队列等待,并按照队列的方式顺序的获取锁。
非公平锁就是新来的线程可以跟阻塞队列的队头争夺一把锁,争夺不过才会添加到队尾。这种情况下,后到的线程有可能无需进入等待队列直接获取锁。
Synchronized 和 lock 默认都是非公平锁。lock 可以通过构造函数的方式改为公平锁。
非公平锁性能高于公平锁。因为当一个线程执行完释放锁时,阻塞的线程需要被唤醒,这个过程有些漫长。在等待的时间如果有一个活跃的线程想争夺这把锁,就把锁让给他,减少等待的时间。
2. 乐观锁/悲观锁
乐观锁和悲观锁是一种概念。
乐观锁:很乐观,每次拿数据时都认为数据没有被修改,所以先不加锁。通过判断其版本号来判断此数据是否发生改变。
悲观锁:很悲观,每次拿数据时都认为别人会修改数据,这时就要上锁来阻止别人进行修改。
乐观锁一般采用 CAS(compare and swapper)比较并交换的方式来实现。
- 使用 volatile 关键字修饰的变量作为版本号,这是因为 volatile 具有可见性。
实现思路:参考 AtomicInteger 的实现:
- 先通过 get() 方式从内存中获取到变量的原先值,(这个值当成版本号)。
- 接下来修改值时调用 unsafe 里的 compareAndSwapper() 方法。
- 该方法需要传入内存中的基址,偏移量,旧值(版本号),更新的值)
- 如果旧值和内存里的值一样,就进行交换,如果不一样,说明被人改了,则停止交换,返回 false。
- 交换失败后若还想改变,则必须重新 get() 内存里的新值,在进行 CAS。
上述有种缺陷:因为该思路将自身值作为版本号,可以任意改变,而正常的版本号是不断增加的。造成的问题:
- 若两个线程同时从内存种取值,取到都是 A。
- 第一个线程停止,第二个线程把 A 换为 B。
- 再来一个线程,把 B 重新换回 A。
- 这时第一个线程执行,他会认为此变量根本没有发生变化。
解决方法:不把自身作为版本号,而是再新建一个字段作为版本号,此版本只能增加,不能回溯。但此时只是版本号来进行 CAS,而需要同步的变量只是做普通的改变,这也会造成并发异常。解决方法还是得加锁。
if (version == UNSAFE.getObject()){ // 版本号相比较,比较成功修改
synchronized(对象){
// 对象赋值 // 赋值完再更改版本号
// 更改版本号
}
}
悲观锁就比较暴力,直接加锁。
3. 自旋锁
由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短。所以当一个线程需要等到锁时没有必要挂起,因为用户态和内核态之间的切换十分影响性能。
自旋的利用 CAS 操作,比较版本号是否相同,如果相同则得到所,不相同就一直循环获取锁,让其处于活跃态,从而不用挂起线程。
4. 自适应自旋锁
由于一直循环也十分耗费资源。自旋的时间并不是固定的,于是采取了一种方法,当超过了时间就不再进行循环,而是直接将线程挂起。
jdk1.7 中 concurrentHashMap 添加就是采用此操作。
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash); // 得到链表的第一个节点
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // 重复尝试
while (!tryLock()) { // 自旋操作,没有获取到锁
HashEntry<K,V> f;
if (retries < 0) {
if (e == null) { // 首节点为 null
if (node == null)
node = new HashEntry<K,V>(hash, key, value, null); // 给 node 创建对象
retries = 0; // 重复尝试
}
else if (key.equals(e.key)) // 添加的节点已存在
retries = 0;
else
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
// 如果尝试次数大于默认的最大尝试次数,就使用 lock 阻塞。减少资源消耗,自适应自旋
lock();
break;
}
else if ((retries & 1) == 0 && (f = entryForHash(this, hash)) != first) {
// 判断首节点是否已经改变,已经改变
e = first = f; // 更换首节点
retries = -1; // 重新进行尝试,查看当前线程添加的节点是否是新添加的节点
}
}
return node; // 获得锁时退出循环,并返回此节点
}
复制代码
5. 可重入锁
可重入锁就是当线程已获取到了 A 锁,当在执行阶段又需要获取 A 锁,并不会因为 A 锁被人拿走了而进行阻塞,而是因为自己有此锁继续执行。
Synchronized 和 ReentrantLock 都是可重入锁,只不过 Synchronized 自动获取和自动释放锁。ReentrantLock 得手动获取和释放,并且获取锁的次数必须和释放锁的次数相同,否则会造成死锁。
6. 读写锁(共享锁/互斥锁)
ReentrantLock 类具有完全互斥的效果,同一时间只有一个线程在执行,效率低下。
JDK 提供了一种读写锁 -- ReentrantReadWriteLock 类,使用它可以在进行一些操作时不需要同步执行,提高效率。
读锁之间不互斥,读锁和写锁互斥,写锁和写锁互斥(只要出现写锁就互斥)。
Synchronized 的锁机制
从 JDK1.6 版本后,Synchronized 本身也在不断优化锁的机制,有些情况下它并不是一个很重量级的锁。优化机制包括自适应锁,自旋锁,轻量级锁,重量级锁。
Java 对象头
java 对象分为三部分:对象头,实体数据,对齐填充符。
- 对象头:
- Mark Word
- 对象的 HashCode
- 分代年龄
- GC 标记
- 锁的标记
- 指向类的指针
- 数组长度
- Mark Word
- 实例数据
- 对齐填充符
在无锁的状态下,Mark Word 会记录:对象的 HashCode,分代年龄,是否是偏向锁,锁标志。
偏向锁
偏向锁的设计理念:
- 由于每次进入和退出同步块都需要获取和释放锁,十分浪费资源。
- 经过大量的验证,发现很多情况下都是同一个线程来获取锁。
- 于是就理想化的让这个锁一直给这个线程。
要保证锁是由一个线程来获取,就必须在锁的对象头上添加此线程的 ID。于是偏向锁状态下,Mark Word 会记录:线程对象的 HashCode,分代年龄,是否偏向锁,锁标志。
执行流程:
- 当锁第一次被线程获取,就将线程 Hashcode 添加到锁的对象头里。
- 线程执行完后并不释放锁。
- 当第二次获取锁,会先判断此线程是否和对象头记录的线程一致,一致的话就直接运行同步代码。
- 若不一致,则锁会升级/膨胀,变成轻量级锁。
优点:在没有竞争或者只有一个线程使用锁的情况下,偏向锁节省了获取和释放锁对性能的损耗。
轻量级锁
轻量级锁状态下,Mark Word 会记录:指向线程栈中锁记录的指针,锁标志位。
- 虚拟机会在线程栈中创建一块内存 Lock Record 来存放信息。(从锁的 Mark Word 中 copy)
- 当线程要获取锁时,会进行 CAS 操作,将锁的 Mark Word 更新为指向栈中锁记录的指针。
- 如果 CAS 操作成功,则表示该线程获取到锁。
- CAS 失败,表示锁被别的线程获取到,采用自旋锁的方式来等待获取锁。
优点:避免在了线程的阻塞,当线程获取不到锁时,会进行自旋,而不会阻塞,造成系统调用内核态和用户态。
缺点:如果存在大量竞争,轻量锁采用的 CAS 和自旋操作会大量的消耗资源,程序的性能反而会下降。
适用场景:在没有多线程竞争uo少量线程竞争的前提下,使用轻量级锁会减少系统在用户态和内核态之间的转换,提高性能。
重量级锁
重量级锁是依赖对象内部的 monitor 锁来实现的,而 monitor 又依赖操作系统的 MutexLock(互斥锁)来实现,所以重量级锁也称为互斥锁。
重量级锁需要阻塞线程,唤醒线程,释放锁,消耗资源很大。
链接: https://www.douban.com/note/797590013/
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处