1.什么是锁
针对于一个共享资源,如果有两个或两个以上的线程访问该资源,可能会导致该共享资源最后的结果与我们预期的结果不一致。比如一个共享变量,其中A线程将其从0循环加一十次,最后结果为十,但是再A线程对该变量循环加一的时候,有个B线程进行了改边该变量,那么可能最后A线程执行结束之后,其结果不一定为十。这样其结果对于A线程来说就不是很正确了,因此我们为了保证结果的正确,再A线程操作该变量时,需要对该变量加上一个互斥条件,让其他线程不能操作该变量,这样就能保证其结果的正确性,因此,这个互斥条件就可以说是一把锁,锁住了该变量。
2.锁的几个概念
死锁
线程之间互相等待着对方释放资源,而自己又持有着对方需要的资源,因此就造成了死锁。
死锁的四个必要条件:
- 互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,那么其他进程只能等待。
- 不可剥夺条件:进程获得的资源在使用完毕之前,不能被其他进程强行夺走,只能由获得该资源的进程自己来释放。
- 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求时,该资源已被其他进程占有,此时进程被阻塞,但对自己获得的资源保持不放。
- 循环等待条件:多个进程间形成首尾相接,循环等待资源的关系。
活锁
活锁与死锁正好相反,活锁是指拿到了资源,然后又互相释放,都不执行
重入锁
重入锁是指一个线程在拥有当前资源的锁之后,可以再次拿到该资源的锁而不被阻塞。
自旋锁
自旋锁是指线程在没有获得锁时,不是被直接挂起,而是执行一个空循环,这种操作就称为自旋。一般自旋的次数默认为十次。
自旋的主要目的是为了减少线程挂起时的消耗。但是如果线程会长时间的持有锁,即使线程自旋之后也会被挂起,那么这个自旋就是在浪费资源了。
自适应自旋锁
自适应自旋锁是指能够进行自动调节自旋锁的自旋次数,,如果一个线程通过自旋获取到锁,那么会认为它下次还能够获取到锁,则会将其自旋次数进行相应增加,而一个线程在本次自旋时没有获取到锁,那么则会自动在下次将其自旋次数进行相应的减少,直到其不进行自旋,直接挂起。
偏向锁
偏向锁是指当第一个线程请求时,会判断对象头里的ThreadId的值,如果为空,则让该线程持有偏向锁,同时将ThreadId设为该线程,下次该线程在请求时,如果线程id与ThreadId相等,则该线程不会再重复获取锁。
如果有其他线程来请求,则会将偏向锁撤销,升级为轻量级锁。如果竞争十分激烈则会将轻量级锁再升级为重量级锁。
锁销除
锁销除是指在编译期间利用“逃逸分析技术”来讲那些不存在竞争但是加了锁的代码的锁失效。这样就减少了锁的请求和释放操作,降低了资源消耗。
锁粗化
锁粗化是指将一些小粒度的锁合起来,变为一个更大力度的锁,比如在循环中加锁,然后循环中就一个递增操作,此时可以直接将锁放在循环外面,这样同样减少了锁的来回请求和释放,降低了资源消耗。
类锁和对象锁
类锁占用的资源时类级别的,对象锁占用的资源则时对象级别。
修饰普通方法,或者this则表示修饰的为对象,是对象锁。
而修饰静态资源,或者直接修饰类,则是类锁。
类锁和对象锁是两个不一样的锁,控制不同的区域,它们之间互不干扰。
3.锁的特性与分类
按照线程要不要锁住资源可以分为乐观锁和悲观锁
按照线程之间竞争锁时要不要排队分为公平锁和非公平锁
按照一个资源能不能被一个线程多次获取分为可重入锁和不可重入锁
按照多个线程能不能共享一把锁分为共享锁和排他锁
按照线程在竞争资源时的竞争激烈程度可以分为下面四个状态:无锁,偏向锁,轻量级锁,重量级锁
4.各种锁的说明
1.乐观锁与悲观锁
悲观锁:悲观锁是指对于同一数据的并发操作,其认为在自己使用数据的同时,一定会有其他线程来进行操作该数据,因此在使用数据时会先加锁,确保在使用的过程中,数据不会被别的线程修改。在java中synchronized关键字和Lock的实现类都是悲观锁。由于悲观锁在每次操作数据的时候都会对数据进行先加锁,让其他线程不会对该数据产生影响,因此,其比较适合写数据的场景,适合写多读少的情景。
悲观锁的使用样例:
// 使用synchronized关键字实现悲观锁
public synchronized void testPessimisticLock() {
}
// 使用Lock进行加锁实现悲观锁
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void testPessimisticLock() {
lock.lock();
// 共享资源
lock.unlock();
}
乐观锁:乐观锁与悲观锁正好相反,其认为对于同一数据的并发操作,认为在自己使用数据的同时,不会有其他线程来进行操作该数据。所以其在操作数据的时候不会对数据进行加锁,而是在修改完之后,进行提交时校验一下数据是否被修改过,如果没有被修改过则当前线程修改成功,如果被修改过则当前线程修改失败。其在java中的实现主要是无锁编程,最常用的方式就是使用CAS算法。由于乐观锁在操作数据的时候不会进行加锁,对读操作比较友好,所以其比较适合一些读多写少的场景。
CAS是compare and swap比较并交换,其主要的参数有三个分别为:
V:需要读写的内存值
A:进行比较的值
B:要写入的新值
当且仅当内存值与进行比较的值相等时,才会更新要写入的值,如果不相等,则不进行更新。需要注意的是其整体是一个原子操作,所以不用担心在比较之后会有其他线程更改V值。
另外CAS也会存在下面的一些问题:
- ABA问题:CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。
解决ABA问题的方法就是通过加一个版本号进行控制,修改了一次就将版本号进行加1。这样就能够避免ABA问题了。 - 循环时间长,开销大:CAS操作如果长时间不成功,则会一直自旋,增加cpu的开销。
- 只能保证一个共享变量的原子操作:对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
但是从jdk 1.5之后,其提供了一个AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
乐观锁的样例:
// AtomicInteger就是使用CAS算法来实现递增的。
private AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet(); //执行自增1
2.公平锁和非公平锁
公平锁:公平锁是多个线程按照申请锁的顺序来获取锁,线程会直接进入队列的队尾排队,只有队列中的第一个线程才能够获取锁。因此这种机制基本上每个线程都会获取到锁,只是时间的早晚而已,不会存在线程饿死的情况。但是这种机制效率会相应的低一些,因为每个线程进来除了第一个线程之外都会进行阻塞,需要cpu来进行唤醒。消耗会比较大些。
非公平锁:非公平锁是线程在加锁时会尝试获取锁,如果获取到锁则该线程不需要阻塞,会直接执行,获取不到锁才会到等待队列的队尾等待。因此这种机制可能会存在后申请锁的线程先获取到锁的情况,这样就可能会造成线程饥饿,甚至饿死,但是其也减少了阻塞线程,唤醒线程的开销。
这里可以找一个类的公平锁和非公平锁的加锁方式来看一下二者的区别:
// 公平锁
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
// 非公平锁
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;
}
从上面的公平锁和非公平锁加锁的代码可以看出,公平锁的加锁方式比非公平锁的加锁方式判断条件多了个!hasQueuedPredecessors()
,其具体的源码如下:
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
从上面的源码可以看出hasQueuedPredecessors()
方法主要是用来判断当前线程是否是等待队列中的第一个线程。是的话则返回true,否的话则返回false。
因此,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获取锁的情况。
3.可重入锁和非可重入锁
可重入锁:可重入锁又名递归锁,指同一个线程可以多次获取同一个资源的锁,并不会因为之前已经获取过还没有释放而阻塞。java中ReentrantLock和synchronized都是可重入锁。可重入锁可以在一定程度上避免死锁。
可重入锁的源码逻辑如下:
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;
}
从上面的代码可以看出其主要的是current == getExclusiveOwnerThread()
判断当前持有锁的线程是否是当前线程,如果是当前线程则持有的数量加1,返回true。
而释放锁的逻辑也会进行判断当前持有锁的数量,只有当当前持有锁的数量为0的时候才会真正的释放锁,如果不为0的时候只是更新一下持有锁的数量,并不会真正的释放锁。具体代码如下:
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
非可重入锁:非可重入锁则是只能被一个线程持有一次,不能多次进行持有。如果再次尝试获取锁的话则会阻塞。
从源码可以知道非可重入锁是直接尝试获取锁的,获取成功则尺有锁,返回true,获取失败则直接返回false。另外其释放锁资源也是直接将status设置为0
protected boolean tryAcquire(int acquires) {
if (this.compareAndSetState(0, 1)) {
this.owner = Thread.currentThread();
return true;
} else {
return false;
}
}
4.共享锁和排他锁
共享锁:共享锁是指该锁可以被多个线程持有,如果一个线程对一个共享数据加上共享锁之后,那么其他线程同样能对该共享数据添加共享锁,但是不能够添加排他锁。另外,获取共享锁的线程只能够读数据,不能够进行修改数据。其中java中最常用的共享锁就是读锁。
可以从读锁加锁的源码来看一下共享锁原理:
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
// 获取锁的数量
int c = getState();
// 判断当前已经有其他线程加了写锁,这边直接失败
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
......
}
可以从上面看出,其只判断了是否加了写锁,并没有进行判断是否加了读锁。
排他锁:排他锁是指该锁只能被一个线程持有,如果一个线程对一个共享数据加上排他锁之后,那么其他线程不能在对该共享数据加任何锁,其中java中最常用的排他锁是写锁。
可以从写锁加锁的源码来看一下排他锁原理:
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
// 获取当前锁的个数
int c = getState();
// 获取其中写锁的个数
int w = exclusiveCount(c);
if (c != 0) {
// 如果当前写锁个数为0,或者持有锁的不是当前线程(表示可重入)
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 如果持有锁的数量加上当前线程数量大于最大值
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
setState(c + acquires);
return true;
}
if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
可以从上面写的加锁过程看出,当当前有锁时,且加的锁不是写锁或加锁的线程不是当前线程时其才会不能加锁,当无锁时,只有加锁过程失败时才会不能加锁。
5.无锁,偏向锁,轻量级锁,重量级锁
无锁:四种锁状态之一,无锁是指没有对资源进行锁定,所哟肚饿线程都能访问并修改同一个资源,但同时只有一个线程能够修改成功。
无锁的特点就是通过循环实现的,线程会通过不断地循环来尝试修改资源。只有修改成功才会结束循环,如果修改不成功,则会一直及逆行循环。其中最典型的代表就是CAS算法。
偏向锁:偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。关于偏向锁可以看上面的偏向锁。
轻量级锁:当锁是偏向锁的时候,被另外的线程访问,那么偏向锁就会自动升级为轻量级锁,其他线程会通过自旋的形式去尝试获取锁,不会阻塞。
重量级锁:当当前锁为轻量级锁的时候,又有新的线程来访问,锁之间的竞争加剧,那么锁就会从轻量级锁,自动升级为重量级锁。
整体锁之间的状态升级为:无锁->偏向锁->轻量级锁->重量级锁
同时这四个锁状态之间只能够升级不能降级。
另外这四种锁状态是通过32位JVM的Mark Word的存储内容来标识的,具体如下:
锁状态 | 25bit | 4bit | 1bit(是否偏向锁) | 2bit(锁标志位) |
---|---|---|---|---|
无锁 | 对象的hashCode | 分代年龄 | 0 | 01 |
偏向锁 | 前23位存的位线程ID,后2位存的位Epoch(本质位一个时间戳,表示偏向锁的有效性) | 分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
重量级锁 | 指向栈中锁记录的指针 | 10 | ||
GC标记 | 空 | 11 |