文章目录
- 从线程是否需要对资源加锁可以分为 悲观锁 和 乐观锁
- 从资源已被锁定,线程是否阻塞可以分为 自旋锁
- 从多个线程并发访问资源,也就是 Synchronized 可以分为 无锁、偏向锁、 轻量级锁 和 重量级锁
- 从锁的公平性进行区分,可以分为公平锁 和 非公平锁
- 从根据锁是否重复获取可以分为 可重入锁 和 不可重入锁
- 从那个多个线程能否获取同一把锁分为 共享锁 和 排他锁(独享锁)
什么是串行、并发、并行
- 串行:一个线程执行到底,相当于单线程。
- 并发:多个线程交替执行,抢占cpu的时间片,但是速度很快,在外人看来就像是多个线程同时执行。
- 并行:多个线程在不同的cpu中同时执行。
并发与并行的区别
- 并发严格的说不是同时执行多个线程,只是
线程交替执行且速度很快,相当于同时执行
。 - 而并行是同时执行多个线程,也就是多个cpu核心同时执行多个线程。
浅谈锁的作用
1.锁有什么作用呢
我们用手机锁是为了保障我们的隐私安全,使用门锁是为了保障我们的财产安全,准确的来说我们使用锁就是为了安全。那么在生活中我们可以加锁来保障自己的隐私和财产安全
2.Java中的锁有什么作用呢
Java中的锁准确的来说也是为了保证安全,不过不同的是Java中加锁准确的来说是为了保证并发安全,同时也是为了解决内存中的一致性,原子性,有序性三种问题。在Java中提供了各式各样的锁,每种锁都有其自身的特点和适用范围。所以我们都要熟悉锁的区别和原理才能正确的使用。
3.为什么要用锁
锁-是为了解决并发操作引起的脏读、数据不一致的问题。
一.Java锁分类
1.公平锁/非公平锁
- 公平锁指多个线程按照
申请锁的顺序
来获取锁。 - 非公平锁指多个线程获
取锁的顺序并不是按照申请锁的顺序
,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转
或者饥饿现象
。
对于
Java ReentrantLock
而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于Synchronized
而言,也是一种非公平锁。由于其并不像ReentrantLock
是通过AQS
的来实现线程调度,所以并没有任何办法使其变成公平锁。
在锁中也是有公平和不公平滴,公平锁如其名讲究的是一个公平,所以多个线程同时申请锁的话,线程会放入一个队列中,在队列中第一个进入队列的线程才能获取锁资源,讲究的是先到先得。
公平锁: 食堂打饭的时候,我同学一放学去食堂排队这样的话才能尽快的打上饭,在排队的过程食堂阿姨是公平的每个人排队的话都能吃到饭,线程也是如此。
非公平锁:我那个同学去食堂排队打饭了但是有人却插队,食堂阿姨却不公平直接给插队的人打饭却不给他打,划重点非公平锁先到不一定先得。
公平锁也是有缺点的:当一个线程获取资源后在队列中的其他的线程就只能在阻塞,CPU的使用公平锁比非公平锁的效率要低很多。因为CPU唤醒阻塞线程的开销比非公平锁大。我们来看一个一个例子:
在Java中ReentrantLock
提供了公平锁和非公平锁的实现。
使用公平锁
ReentrantLock默认就是非公平的锁,我们来看一下公平锁的例子:
执行结果
- 公平锁的输出结果是按照顺序来的,先到先得。
使用非公平锁
执行结果
使用非公平锁的话最后输出的结果是完全没有顺序的,先到不一定先得。
2.可重入锁/不可重入锁
可重入锁:又名递归锁
,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁
。
广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。ReentrantLock和synchronized都是可重入锁
// 演示可重入锁的代码层意思
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB(); // 因为获取了setA()的锁(即获取了方法外层的锁)、此时调用setB()将会自动获取setB()的锁,如果不自动获取的话方法B将不会执行
}
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB
可能不会被当前线程执行,可能造成死锁。
不可重入锁: 与可重入锁相反,不可递归调用,递归调用就发生死锁。
看到一个经典的讲解,使用自旋锁来模拟一个不可重入锁,代码如下:
import java.util.concurrent.atomic.AtomicReference;
public class UnreentrantLock {
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
//这句是很经典的“自旋”语法,AtomicInteger中也有
for (;;) {
if (!owner.compareAndSet(null, current)) {
return;
}
}
}
public void unlock() {
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}
代码也比较简单,使用原子引用类
来存放线程,同一线程两次调用lock()
方法,如果不执行unlock()
释放锁的话,第二次调用自旋的时候就会产生死锁,这个锁就不是可重入的,而实际上同一个线程不必每次都去释放锁再来获取锁,这样的调度切换是很耗资源的。
把它变成一个可重入锁:
import java.util.concurrent.atomic.AtomicReference;
public class UnreentrantLock {
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
private int state = 0;
public void lock() {
Thread current = Thread.currentThread();
if (current == owner.get()) {
state++;
return;
}
//这句是很经典的“自旋”式语法,AtomicInteger中也有
for (;;) {
if (!owner.compareAndSet(null, current)) {
return;
}
}
}
public void unlock() {
Thread current = Thread.currentThread();
if (current == owner.get()) {
if (state != 0) {
state--;
} else {
owner.compareAndSet(current, null);
}
}
}
}
在执行每次操作之前,判断当前锁持有者是否是当前对象,采用state
计数,不用每次去释放锁。
ReentrantLock中可重入锁实现
这里看非公平锁的锁获取方法
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//就是这里
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
在AQS
中维护了一个private volatile int state
来计数重入次数,避免了频繁的持有释放操作,这样既提升了效率,又避免了死锁。
3.独享锁/共享锁
独享锁和共享锁在你去读C.U.T
包下的ReeReentrantLock
和ReentrantReadWriteLock
你就会发现,它俩一个是独享锁,一个是共享锁。
-
独享锁是指该锁
一次只能被一个线程所持有
。每次只有一个线程能霸占这个锁资源,而其他线程就只能等待当前获取锁资源的线程释放锁才能再次获取锁,
ReentrantLock
就是独占锁,其实准确的说独占锁也是悲观锁,因为悲观锁抢占资源后就只能等待释放其他线程才能再次获取到锁资源。。 -
共享锁是指该锁
可被多个线程共有
。共享锁其实也是
乐观锁
它放宽了锁的策略允许多个线程同时获取锁。在并发包中ReadWriteLock
就是一个典型的共享锁。它允许一个资源可以被多个读操作访问,或者被一个 写操作访问,但两者不能同时进行。
.
典型的就是ReentrantReadWriteLock
里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占。另外读锁的共享可保证并发读是非常高效的,但是读写和写写,写读都是互斥的。
独享锁与共享锁也是通过AQS
来实现的,通过实现不同的方法,来实现独享或者共享。对于Synchronized而言,当然是独享锁。
4.互斥锁/读写锁
上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现
互斥锁
-
在访问共享资源之前进行加锁操作,在访问完成之后进行解锁操作。加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。
-
如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都被变成就绪状态, 第一个变为就绪状态的线程又执行加锁操作,那么其他的线程又会进入等待。在这种方式下,只有一个线程能够访问被互斥锁保护的资源
读写锁
读写锁既是互斥锁,又是共享锁,read模式是共享,write模式是互斥(排它锁)的。
- 读写锁有三种状态:
读加锁状态
、写加锁状态
和不加锁状态
- 读写锁在Java中的具体实现就是
ReadWriteLock
一次只有一个线程可以占有写模式
的写锁,但是多个线程可以同时占有读模式
的读锁。- 只有一个线程可以占有写状态的锁,但可以有多个线程同时占有读状态的锁,这也是它可以实现高并发的原因。
- 当其处于
写状态锁
下,任何想要尝试获得锁的线程都会被阻塞,直到写状态锁被释放;如果是处于读状态锁
下,允许其它线程获得它的读状态锁,但是不允许获得它的写状态锁,直到所有线程的读状态锁被释放; 为了避免想要尝试写操作的线程一直得不到写状态锁,当读写锁感知到有线程想要获得写状态锁时,便会阻塞其后所有想要获得读状态锁的线程。
所以读写锁非常适合资源的读操作
远多于写操作
的情况。
5.乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁
,而是指看待并发同步
的角度。
-
悲观锁 认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此
对于同一个数据的并发操作,悲观锁采取加锁的形式
。悲观的认为,不加锁的并发操作一定会出问题。总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中
synchronized
和ReentrantLock
等独占锁
就是悲观锁思想的实现。
线程A抢占到资源后线程B就陷入了阻塞中,然后就等待线程A释放资源。
当线程A释放完资源后线程B就去获取锁开始操作资源˛悲观锁保证了资源同时只能一个线程进行操作 -
乐观锁 则认为对于同一个数据的并发操作,是不会发生修改的 。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用
版本号机制
和CAS算法
实现。乐观锁适用于多读
的应用类型,这样可以提高吞吐量
,像数据库提供的类似于write_condition
机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic
包下面的原子变量类
就是使用了乐观锁
的一种实现方式CAS
实现的。
应用场景
-
悲观锁适合
写操作非常多
的场景,乐观锁适合读操作非常多
的场景,不加锁会带来大量的性能提升。 -
悲观锁在Java中的使用,就是利用各种锁。乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,
典型的例子就是原子类
,通过CAS自旋实现原子操作的更新
。重量级锁
是悲观锁的一种,自旋锁、轻量级锁与偏向锁
属于乐观锁
使用乐观锁和悲观锁
可以使用synchronized关键字来实现悲观锁,乐观锁可以使用并发包下提供的原子类。
6.分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap
而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
并发容器类的加锁机制是基于粒度更小的分段锁,分段锁也是提升多并发程序性能的重要手段之一。
- 以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为
Segment
,它即类似于HashMap(JDK7与JDK8中HashMap的实现)
的结构,即内部拥有一个Entry数组
,数组中的每个元素
又是一个链表
;同时又是一个ReentrantLock
(Segment继承了ReentrantLock
)。 - 当需要
put
元素的时候,并不是对整个hashMap进行加锁
,而是先通过hashCode
来知道他要放在那一个分段
中,然后对这个分段进行加锁
,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。但是,在统计size的时候,可就是获取hashMap全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是:细化锁的粒度
,当操作不需要更新整个数组的
时候,就仅仅针对数组中的一项
进行加锁操作。
在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将同时导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。
我们一般有三种方式降低锁的竞争程度:
- 减少锁的持有时间
- 降低锁的请求频率
- 使用带有协调机制的独占锁,这些机制允许更高的并发性。
在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这称为分段锁。
其实说的简单一点就是:
容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
.
比如:在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分部,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程。
7.偏向锁/轻量级锁/重量级锁
锁的状态:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
- 锁的状态是通过
对象监视器
在对象头中的字段来表明的。- 四种状态会随着竞争的情况逐渐升级,而且是
不可逆
的过程,即不可降级。
这四种状态都不是Java语言中的锁,而是Jvm为了提高锁的获取与释放效率而做的优化
(使用synchronized时)。
这四种锁是指锁的状态
,并且是 针对 Synchronized。在Java5
通过引入锁升级
的机制来实现高效Synchronized
。这三种锁的状态是 通过对象监视器在对象头
中的字段来表明的。偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
-
偏向锁的适用场景:
始终只有一个线程在执行同步块
,在它没有执行完释放锁之前
,没有其它线程去执行同步块
,在锁无竞争
的情况下使用,一旦有了竞争
就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word
操作;在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stw
,导致性能下降,这种情况下应当禁用
; -
轻量级锁:是指当
锁是偏向锁的时候
,被另一个线程
所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋
的形式尝试获取锁,不会阻塞,提高性能
。 -
重量级锁:是指当
锁为轻量级锁的时候
,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数
的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁
。 重量级锁会让其他申请锁的线程进入阻塞
,性能降低
。
8.自旋锁
我们知道CAS算法是乐观锁的一种实现方式,CAS算法中又涉及到自旋锁,
8.1.简单了解一下CAS算法?
CAS是英文单词Compare and Swap
(比较并交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步
,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
Java提供的非阻塞原子性操作。在不使用锁的情况下实现多线程下的同步。在并发包中(java.util.concurrent)原子性类都是使用CAS来实现乐观锁的。CAS通过硬件保证了比较更新的原子性,在JDK中Unsafe提供了一系列的compareAndSwap*方法,这里就不深究Unsafe这个类了。
CAS算法涉及到三个操作数:
- 预期值的 A
- 内存中的V
- 将要修改的B
CAS操作过程就是将内存中的将要被修改的数据与预期的值进行比较,如果这两个值相等就修改值为新值,否则就不做操作也就是说CAS需要三个操作值:
简单的来说CAS就是一个死循环,在循环中判断预期的值和内存中的值是否相等,如果相等的话就执行修改,如果如果不相等的话就继续循环,直到执行成功后退出。
CAS的问题
CAS虽然很牛逼但是它也存在一些问题比如ABA问题
- 举个例子:现在有内存中有一个共享变量X的值为A,这个时候出现一个变量想要去修改变量X的值,首先会获取X的值这个时候获取的是A,然后使用CAS操作把X变量修改成B。这样看起来是没有问题,那如果在线程1获取变量X之后,执行CAS之前出现一个线程2把X的值修改成B然后CAS操作执行又修改成了了A,虽然最后执行的结果共享变量的值为A但是此A已经不是线程1获取的A了。
这就是经典的ABA问题。产生ABA问题是因为变量的状态值发生了环形转换,A可以到B,B可以到A,如果A到B,B到C就不会发生这种问题。
解决办法:
- 在
Java 5
后加入了AtomicStampedReference
方法给每个变量加入了一个时间戳
来避免ABA问题。
循环开销大的问题
CAS一直循环直到预期和内存相等修改成功。同时还有只能保证一个共享变量的原子性的问题,不过在Java 5
之后加入了AtomicReferenc
e类来保证引用对象之间的原子性。
8.2.什么是自旋锁?
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁
, 减少线程上下文切换的消耗,缺点是循环会消耗CPU。- 它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个线程获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行线程保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。
- 自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做
内核态
和用户态
之间的切换进入阻塞挂起状态
,它们只需要等一等(自旋)
,等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程
和内核
的切换
的消耗。 - 自旋锁
尽可能的减少线程的阻塞
,适用于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升
,因为自旋的消耗会 小于线程阻塞挂起再唤醒
的操作的消耗。 - 但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就
不适合使用自旋锁
了,因为自旋锁在获取锁前
一直都是占用cpu做无用功
,同时有大量线程
在竞争一个锁
,会导致获取锁的时间很长
, 线程自旋的消耗大于线程阻塞挂起操作的消耗
,其它需要cpu的线程又不能获取到cpu,造成cpu的浪费
。
8.3.Java如何实现自旋锁?
简单栗子:
public class SpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
lock()
方法利用的CAS
,当第一个线程A
获取锁的时候,能够成功获取到,不会进入while循环
,如果此时线程A
没有释放锁,另一个线程B
又来获取锁,此时由于不满足CAS
,所以就会进入while循环
,不断判断是否满足CAS
,直到线程A
调用unlock()
方法释放了该锁。
8.4.自旋锁存在的问题
- 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
- 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在
“线程饥饿”
问题。
- 线程饥饿:线程因无法访问所需资源而无法执行下去的情况。
- “不患寡,而患不均(不应担心财富不多,只需担心财富分配不均)”,如果线程优先级“不均”,在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
- 解决“饥饿”问题的方案很简单,有三种方案:
一是保证资源充足
二是公平地分配资源
三就是避免持有锁的线程长时间执行
这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
举例剖析线程死锁与饥饿的区别
死锁与活锁的区别,死锁与饥饿的区别
java多线程中的死锁、活锁、饥饿、无锁都是什么鬼?
8.5.自旋锁的优点
- 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active(活动)的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
- 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。(线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
8.6.可重入的自旋锁和不可重入的自旋锁
8.3.
那段代码,仔细分析一下就可以看出,它是不支持重入的,即当一个线程第一次已经获取到了该锁,在锁释放之前又一次重新获取该锁,第二次就不能成功获取到。由于不满足CAS,所以第二次获取会进入while循环等待,而如果是可重入锁,第二次也是应该能够成功获取到的。
而且,即使第二次能够成功获取,那么当第一次释放锁的时候,第二次获取到的锁也会被释放,而这是不合理的。
为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。
public class ReentrantSpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
private int count;
public void lock() {
Thread current = Thread.currentThread();
if (current == cas.get()) { // 如果当前线程已经获取到了锁,线程数增加一,然后返回
count++;
return;
}
// 如果没获取到锁,则通过CAS自旋
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread cur = Thread.currentThread();
if (cur == cas.get()) {
if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟
count--;
} else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
cas.compareAndSet(cur, null);
}
}
}
}
8.7.自旋锁与互斥锁
- 自旋锁与互斥锁都是为了实现保护资源共享的机制。
- 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。
- 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放。
8.8.自旋锁总结
- 自旋锁:线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
- 自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)。
- 自旋锁如果持有锁的时间太长,则会导致其它等待获取锁的线程耗尽CPU。
- 自旋锁本身无法保证公平性,同时也无法保证可重入性。
- 基于自旋锁,可以实现具备公平性和可重入性质的锁。
二.Synchronized与Lock
Java锁机制可归为 Sychornized锁 和 Lock锁 两类。Synchronized是 基于JVM来保证数据同步
的,而Lock则是 在硬件层面,依赖特殊的CPU指令实现数据同步的
。
Synchronized是一个非公平、悲观、独享、互斥、可重入的重量级锁
ReentrantLock是一个 默认非公平但可实现公平的、悲观、独享、互斥、可重入、重量级锁
ReentrantReadWriteLocK是一个 默认非公平但可实现公平的、悲观、写独享、读共享、读写、可重入、重量级锁
1. Synchronized
在Java 5
之前使用synchronized
关键字保证同步,它可以把任意一个非NULL的对象当作锁。
- 作用于方法时,锁住的是对象的实例 (this)
- 当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程
- synchronized作用于一个对象实例时,锁住的是所有以该对象为锁的代码块
Synchronized的实现如下图所示:
它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
-
Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中
-
Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中
-
Wait Set:哪些调用wait方法被阻塞的线程被放置在这里
-
OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck
-
Owner:当前已经获取到所资源的线程被称为Owner
-
!Owner:当前释放锁的线程
- ContentionList并不是真正意义上的一个队列,仅仅是一个虚拟队列。它只有Node以及对应的Next指针构成,并没有Queue的数据结构。每次新加入Node会在队头进行,通过CAS改变第一个节点为新增节点,同时新增阶段的next指向后续节点,而取数据都在队列尾部进行。
- JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。
- OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。
- 处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。该线程被阻塞后则进入内核调度状态,会导致系统在用户和内核之间进行来回切换,严重影响锁的性能。为了缓解上述性能问题,JVM引入了自旋锁(上边已经介绍过自旋锁)。原理非常简单,如果Owner线程能在很短时间内释放锁资源,那么哪些等待竞争锁的线程可以稍微等一等(自旋)而不是立即阻塞,当Owner线程释放锁后可立即获取锁,进而避免用户线程和内核的切换。但是Owner可能执行的时间会超过设定的阈值,争用线程在一定时间内还是获取不到锁,这是争用线程会停止自旋进入阻塞状态。基本思路就是先自旋等待一段时间看能否成功获取,如果不成功再执行阻塞,尽可能的减少阻塞的可能性,这对于占用锁时间比较短的代码块来说性能能大幅度的提升!
- Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。
2.Lock锁
与synchronized不同的是,Lock锁是纯Java实现的,与底层的JVM无关。在java.util.concurrent.locks包中有很多Lock的实现类,常用的有ReentrantLock、ReadWriteLock(实现类ReentrantReadWriteLock),实现都依赖java.util.concurrent.AbstractQueuedSynchronizer类(简称AQS)。
2.1.Lock的实现
-
获取锁:首先判断当前状态是否允许获取锁,如果是就获取锁,否则就阻塞操作或者获取失败,也就是说如果是独占锁就可能阻塞,如果是共享锁就可能失败。另外如果是阻塞线程,那么线程就需要进入阻塞队列。当状态位允许获取锁时就修改状态,并且如果进了队列就从队列中移除。
-
**释放锁:**这个过程就是修改状态位,如果有线程因为状态位阻塞的话,就唤醒队列中的一个或者更多线程。阻塞和唤醒,JDK1.5之前的API中并没有阻塞一个线程,然后在将来的某个时刻唤醒它(wait/notify是基于synchronized下才生效的,在这里不算),JDK5之后利用JNI在LockSupport 这个类中实现了相关的特性。
-
有序队列:在AQS中采用CLH队列来解决队列的有序问题。ReentrantLock把所有的Lock都委托给Sync类进行处理,该类继承自AQS。其中Sync又有两个final static的子类NonfairSync和FairSync用于支持非公平锁和公平锁。Reentrant.lock()的调用过程(默认为非公平锁)
如图:
通过上面的过程图和AbstractQueuedSynchronizer的注释可以看出,AbstractQueuedSynchronizer抽象了大多数Lock的功能,而只把tryAcquire(int)委托给子类进行多态实现。tryAcquire用于判断对应线程事都能够获取锁,无论成功与否,AbstractQueuedSynchronizer都将处理后面的流程。AQS会把所有请求锁的线程组成一个CLH的队列,当一个线程执行完毕释放锁(Lock.unlock())的时候,AQS会激活其后继节点,正在执行的线程不在队列当中,而那些等待的线程全部处于阻塞状态,经过源码分析,我们可以清楚的看到最终是通过LockSupport.park()实现的,而底层是调用sun.misc.Unsafe.park()本地方法,再进一步,HotSpot在Linux中中通过调用pthread_mutex_lock函数把线程交给系统内核进行阻塞。
其运行流程如图所示:
与synchronized相同的是,这个也是一个虚拟队列,并不存在真正的队列示例,仅存在节点之间的前后关系。(注:原生的CLH队列用于自旋锁,JUC将其改造为阻塞锁)。和synchronized还有一相同点是当获取锁失败的时候,不是立即进行阻塞,而是先自旋一段时间看是否能获取锁,这对那些已经在阻塞队列里面的线程显然不公平(非公平锁的实现,公平锁通过有序队列强制线程顺序进行),但会极大的提升吞吐量。如果自旋还是获取失败了,则创建一个节点加入队列尾部,加入方法仍采用CAS操作,并发对队尾CAS操作有可能会发生失败,AQS是采用自旋循环的方法,直到CAS成功。
锁的实现依赖于lock()方法,Lock()方法首先是调用acquire(int)方法,不管是公平锁还是非公平锁
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
Acquire()方法默认首先调用tryAcquire(int)方法,而此时公平锁和不公平锁的实现就不一样了。
Sync.NonfairSync.TryAcquire (非公平锁):nonfairTryAcquire方法是lock方法间接调用的第一个方法,每次调用都会首先调用这个方法,实现代码:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
该方法首先会判断当前线程的状态,如果c== 0 说明没有线程正在竞争锁。(反过来,如果c!=0则说明已经有其他线程已经拥有了锁)。如果c==0,则通过CAS将状态设置为acquires(独占锁的acquires为1),后续每次重入该锁都会+1,每次unlock都会-1,当数据为0时则释放锁资源。其中精妙的部分在于:并发访问时,有可能多个线程同时检测到c为0,此时执行compareAndSetState(0, acquires))设置,可以预见,如果当前线程CAS成功,则其他线程都不会再成功,也就默认当前线程获取了锁,直接作为running线程,很显然这个线程并没有进入等待队列。如果c!=0,首先判断获取锁的线程是不是当前线程,如果是当前线程,则表明为锁重入,继续+1,修改state的状态,此时并没有锁竞争,也非CAS,因此这段代码也非实现了偏向锁。
2.2.Lock与synchronized 的区别
- ReentrantLock 拥有Synchronized相同的并发性和内存语义,此外还多了锁投票,定时锁等候和中断锁等候。线程A和B都要获取对象O的锁定,假设A获取了对象O锁,B将等待A释放对O的锁定,如果使用synchronized ,如果A不释放,B将一直等下去,不能被中断。如果使用ReentrantLock,如果A不释放,可以使B在等待了足够长的时间以后,中断等待,而干别的事情
ReentrantLock获取锁定的方式:
a. lock(),如果获取了锁立即返回,如果别的线程持有锁,当前线程则一直处于休眠状态,直到获取锁
b. tryLock(),如果获取了锁立即返回true,如果别的线程正持有锁,立即返回false
c. tryLock(long timeout,TimeUnit unit),如果获取了锁定立即返回true,如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false
d. lockInterruptibly,如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到或者锁定,或者当前线程被别的线程中断
- synchronized是在JVM层面上实现的,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定,但是使用Lock则不行,Lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock()放到finally{}中
- 在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态
Java5的多线程任务包对于同步的性能方面有了很大的改进,在原有synchronized关键字的基础上,又增加了ReentrantLock,以及各种Atomic类。以下是对于其性能的优劣对比:
synchronized:
- 在资源竞争不是很激烈的情况下,偶尔会有同步的情形下,synchronized是很合适的。原因在于,编译程序通常会尽可能的进行优化synchronize,另外可读性非常好
ReentrantLock:
- ReentrantLock提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。在资源竞争不激烈的情形下,性能稍微比synchronized差点点,但是当同步非常激烈的时候,ReentrantLock性能能维持常态
Atomic:
- 与ReentrantLock类似,不激烈情况下,性能比synchronized略逊,而激烈的时候,也能维持常态。激烈的时候,Atomic的性能会优于ReentrantLock一倍左右。但是其有一个缺点,就是只能同步一个值,一段代码中只能出现一个Atomic的变量,多于一个同步无效,因为其不能在多个Atomic之间同步