Java锁机制
本文最后更新于:5 个月前
锁机制是用来保证对象的一致性以及操作的原子性,另一方面也是实现线程安全的重要手段。
公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,有可能会造成优先级反转或者饥饿现象。
ReentrantReadWriteLock
实现了公平锁和非公平锁两种模式。
在公平锁模式下线程按照请求的顺序来获得锁,而非公平模式下则可以插队。我们的期望是所有的锁都是公平的,毕竟插队是一种不好的行为。但实际上非公平锁比公平锁有着更高的并发效率。假设线程 A
持有一个锁,并且线程 B
也请求这个锁,由于该锁被线程 A
占有,所以 B
线 程挂起,当 A
使用结束时释放锁,此时唤醒 B
,B
需要重新申请获得锁。如果同时线程 C
也请求这个锁,并且 C
很可能在 B
获得锁之前已经获得、使用并释放了锁。这样就实现了双赢,B
线程获得的锁没有延迟同时线程 C
也得到了执行,这提高了程序的吞吐率。
可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
ReentrantLock
ReentrantLock
是一个可重入的互斥锁,继承自 Lock
接口。如果说 synchronized
是隐式锁的话,那么 ReentrentLock
就是显式锁。锁的申请、使用、释放都必须显式的申明。Lock
接口提供了以下方法:
1 |
|
ReentrantLock
实现了 Lock
接口,并提供了与 synchronized
相同的互斥性和内存可见性。
那既然如此,为什么还要创建一个类似的锁机制呢?
内置锁虽然好用,但是缺乏一些灵活性,而ReentrantLock
则可以弥补这些不足。
第一,轮询锁与定时锁。
tryLock
方法实现了可定时的与可轮询的锁实现。
与synchronized
相比它有更完善的错误恢复机制。内置锁中死锁是一类严重的错误,只能重启程序。而ReentrantLock
可以使用可定时或者轮询的锁,它会释放已获得的锁,然后再尝试获得所有的锁。在实现具有时间限制的操作时,定时锁也非常也用。如果操作在给定时间内不能给出结果那么就会使程序提前结束。第二,可中断锁获取操作。
lockInterruptibly
方法能够在获得锁的同时保持对中断的响应。而且由于它包含在Lock
中,因此无需创建其他类型的不可中断阻塞机制。
独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有。
共享锁是指该锁可被多个线程所持有。
互斥锁/读写锁
独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
ReadWriteLock
ReentrantReadWriteLock
实现了一种标准的互斥读写锁,继承自 ReadWriteLock
。ReadWriteLock
接口包含以下方法:
1 |
|
ReentrantReadWriteLock
可以这样理解:当执行读操作的时候可以多个线程并发访问;当执行写操作的时候,只可以同时被一个线程访问。所以它使用的场景是读操作多而写操作少的并发场景。
此外,ReentrantReadWriteLock
还可以设置是否为公平锁,是公平锁的话则可以按照排队的顺序获取锁,非公平锁的话则是随机获得。
偏向锁/轻量级锁/重量级锁
偏向锁的适用场景
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致 stop the word(stw)
操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致 stw
,导致性能下降,这种情况下应当禁用;
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
synchronized
synchronized
用来形容方法或者代码块,是 Java
提供的最早的锁机制,支持重入锁。
列举几个注意事项:
第一,使用
synchronized
关键字时一定要尽可能的缩小范围,尽可能的在方法块里需要锁的地方使用,而不是直接用来修饰整个方法。
这需要对方法里面的操作进行分析,哪些需要加锁,哪些不需要加锁,只在需要锁的地方加锁,这样即可以提高程序的效率,同时开放调用方法也减少了线程安全的隐患。第二,
synchronized
提供的锁机制是粗粒度的,当有线程访问当前对象的synchronized
方法或代码块时,其他线程只能等待当前操作结束才可以访问。
这听上去似乎存在一定的性能问题,但java 1.6
以后synchronized
在并发环境下性能得到了大幅提升,因此建议尽可能的使用synchronized
,除非synchronized
满足不了业务需求。
而且synchronized
使用时无需释放锁,而且JVM
还提供了专门的优化支持,因此即使synchronized
是古老的锁,但是它依然适用于绝大多数场景。
volatile
volatile
用来修饰变量保证其可见性。可见性是一种复杂的属性,volatile
变量不会被缓存在寄存器或者其他处理器不可见的地方,在读取volatile
变量时返回的一定是最新写入的值。volatile
不是线程安全的,不能替代锁,它只在特定的场景下使用,使用时要非常小心。
以下场景可以使用 volatile
:
第一,对变量的写入不依赖于变量当前的值,或者你能确保只有单个线程更新变量的值;
第二,该变量不会与其它状态变量一起纳入不变性条件中;
第三,在访问变量时不需要加锁。
自旋锁
在 Java
中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU
。
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
自旋锁尽可能的减少线程的阻塞,适用于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu
做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cpu
的线程又不能获取到 cpu
,造成 cpu
的浪费。
分段锁
分段锁是一种锁的设计,并不是具体的一种锁,对于 ConcurrentHashMap
而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
以 ConcurrentHashMap
来说一下分段锁的含义以及设计思想,ConcurrentHashMap
中的分段锁称为 Segment
,它即类似于HashMap
( JDK7
与 JDK8
中 HashMap
的实现)的结构,即内部拥有一个 Entry
数组,数组中的每个元素又是一个链表;同时又是一个 ReentrantLock
( Segment
继承了 ReentrantLock
)。
当需要 put
元素的时候,并不是对整个 hashmap
进行加锁,而是先通过 hashcode
来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程 put
的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计 size
的时候,可就是获取 hashmap
全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。
因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。
在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
从上面的描述可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
悲观锁在 Java
中的使用,就是利用各种锁。
乐观锁在 Java
中的使用,是无锁编程,常常采用的是 CAS
算法,典型的例子就是原子类,通过 CAS
自旋实现原子操作的更新。
重量级锁是悲观锁的一种,自旋锁、轻量级锁与偏向锁属于乐观锁。
参考博客:
浅谈Java锁机制