Java 锁相关问题

前言

最近总结了 Java 锁的一些相关问题,整理如下,希望对自己和大家的学习略有帮助。

正文

可重入锁

可重入锁通俗来说是当线程请求一个由其它线程持有的对象锁时,该线程会阻塞,而当线程请求由自己持有的对象锁时,如果该锁是可重入锁,请求就会成功,否则阻塞

我们可以根据一个简单例子来看下。

这儿我们首先构造一个不可重入锁,也就是当该线程持有锁后,再进来获取锁的线程(包括自己)都会失败(或者等待锁释放)。

这儿我们用等待来模拟,便于更直观了解不可重入锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyLock {
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException{
while(isLocked){
System.out.println("等待Thread:"+Thread.currentThread().getId()+"释放锁");
wait();
}
isLocked = true;
}
public synchronized void unlock(){
isLocked = false;
notify();
}
}

如上代码可以看到,当一个线程拿到锁后,另外线程(包括自己)在获得锁时需要等待当前线程释放锁。

我们写个测试类来测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class LockTest {
MyLock lock = new MyLock();
public void doSomething() throws InterruptedException{
System.out.println("Thread:"+Thread.currentThread().getId()+" 尝试加锁");
lock.lock();
System.out.println("Thread:"+Thread.currentThread().getId()+" 加锁成功");
doSomethingAgain();
System.out.println("Thread:"+Thread.currentThread().getId()+" 尝试解锁");
lock.unlock();
System.out.println("Thread:"+Thread.currentThread().getId()+" 解锁成功");
}

private void doSomethingAgain() throws InterruptedException{
System.out.println("Thread:"+Thread.currentThread().getId()+" 尝试加锁");
lock.lock();
System.out.println("Thread:"+Thread.currentThread().getId()+" 加锁成功");
System.out.println("do Something");
System.out.println("Thread:"+Thread.currentThread().getId()+" 尝试解锁");
lock.unlock();
System.out.println("Thread:"+Thread.currentThread().getId()+" 解锁成功");
}

public static void main(String[] args) throws InterruptedException{
LockTest test = new LockTest();
test.doSomething();
}
}

上述测试代码的意思是一个线程加锁后,在用该线程去尝试获取锁(可重入)。

可以看到如下输出:

而后该段程序会卡住(死锁),因为自己拿到锁还未释放,不能再去获取锁。

我们知道 Java 中的 ReentrantLock 是可重入锁,我们用它来替换我们的 MyLock 再来测试一下。

1
Lock lock = new ReentrantLock();

可以看到当前线程是可以重入的。

synchronized 是不是可重入锁

根据上面定义,我们简单来找个例子验证下即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SynchronizedTest extends MySynchronized{

public synchronized void doSomething(){
System.out.println("ThreadId:"+Thread.currentThread().getId());
doSomethingAgain();
}

private void doSomethingAgain(){
super.superDoSomething();
}

public static void main(String[] args) {
SynchronizedTest test = new SynchronizedTest();
System.out.println("ThreadId:"+Thread.currentThread().getId());
test.doSomething();
}
}

class MySynchronized{
protected synchronized void superDoSomething(){
System.out.println("ThreadId:"+Thread.currentThread().getId());
System.out.println("do Something");
}
}

可以看到如下输出:

说明 synchronized可重入的。

关于更多 synchronized 内容请看这篇文章 Java synchronized锁机制 .

ReentrantLock 可重入实现

ReentrantLock 底层是根据一个状态 state 参数来控制可重入的,默认为0,加锁后状态会变更。

  1. 一个线程尝试获取锁时,判断 state 是不是0,如果是0,表示没锁,就尝试获取锁;

  2. 如果不是0,判断是不是当前线程持有的改锁,是的话就改变 state状态(可重入);

  3. 如果不是当前线程,说明其他线程正在持有锁,返回加锁失败(或者根据等待时间尝试等待锁释放)。

注意,这儿有以下几点要注意:

  • 重入是有次数限制的,根据代码来看,最大值为 Integer 最大值。

  • ReentrantLock 内部实现了公平锁和非公平锁两种。非公平锁就是线程自由尝试抢占锁;公平锁会有一个线程队列,获取锁时根据队列里数据的先后顺序尝试获取锁。

更多详细内容可以参考这篇文章 ReentrantLock那些事

死锁

什么是死锁

死锁指的是多个线程因竞争资源而造成的一种僵局(相互等待),若无外部作用,这些线程将一直这样下去。

死锁的必要条件

  • 互斥条件:指的是资源在一定时间内只能由一个线程占有并使用。如果有其他线程请求该资源,需要等待。
  • 不剥夺条件:指的是当前线程在未使用完资源之前,不会被其他线程剥夺,资源只能由自己释放。
  • 请求和保持条件:指的是线程1至少已经占用了一个资源,比如A,又提出了新的资源B请求,而此时资源B已被线程2占用,此时请求会被阻塞,线程1此时不会释放资源A。
  • 循环等待条件:存在一种线程资源的循环等待链,链中每一个线程已获得的资源同时被链中下一个线程所请求。

例子

我们来看一个死锁的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class DeadLock implements Runnable{
private static Object o1 = new Object();
private static Object o2 = new Object();
private int type;

public DeadLock(int type) {
this.type = type;
}

public static void main(String[] args) {
DeadLock thread1 = new DeadLock(0);
DeadLock thread2 = new DeadLock(1);
new Thread(thread1).start();
new Thread(thread2).start();
}

@Override
public void run() {
System.out.println(Thread.currentThread().getName());
if(type == 0){
synchronized (o1){
try {
TimeUnit.MILLISECONDS.sleep(1000);
System.out.println("doSomethingWith O1");
}catch (InterruptedException e){
e.printStackTrace();
}
synchronized (o2){
System.out.println("doSomethingWith O2");
}
}
}else{
synchronized (o2){
try {
TimeUnit.MILLISECONDS.sleep(1000);
System.out.println("doSomethingWith O2");
}catch (InterruptedException e){
e.printStackTrace();
}
synchronized (o1){
System.out.println("doSomethingWith O1");
}
}

}
}
}

上述代码很好理解:我们根据 type参数来决定先锁定 o1还是o2,然后启动两个线程,一个先锁定 o1,一个先锁定 o2,而后就会出现死锁。

如何避免死锁

  1. 破坏占用并等待(加锁全部所需资源)

    即线程启动时就拿到所有需要的资源;或者线程启动拿到初步启动所需的资源,后续再逐步释放已有的资源,申请需要的资源。

  2. 不可强占资源(锁应有超时时间)

    即当一个已经持有了一些资源的进程在提出新的资源请求没有得到满足时,等待一定时间后它必须释放已经保持的所有资源,待以后需要使用的时候再重新申请。这就意味着进程已占有的资源会被短暂地释放或者说是被抢占了。

  3. 破坏循环等待条件(按照一定顺序加锁)

    可以通过定义资源类型的线性顺序来预防,比如可将每个资源编号,当一个进程占有编号为 i的资源时,那么它下一次申请资源只能申请编号大于i的资源。

比如上面的例子,我们改成线程池访问模式,将 main 方法内容改造如下。

1
2
3
4
5
6
7
8
static ExecutorService executorService = Executors.newFixedThreadPool(10);
public static void main(String[] args) {
Random random = new Random();
for (int i = 0; i < 10; i++) {
int type = random.nextInt(2);
executorService.submit(new DeadLock(type));
}
}

可以看到会出现死锁,这种情况我们可以使用单一线程池来处理,相当于我们上面说的 1. 加锁全部所需资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d").build();

static ExecutorService executorService =
new ThreadPoolExecutor(1, 1, 5L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),namedThreadFactory,new ThreadPoolExecutor.AbortPolicy());

public static void main(String[] args) {
Random random = new Random();
for (int i = 0; i < 10; i++) {
int type = random.nextInt(2);
executorService.submit(new DeadLock(type));
}
}

如下输出:

可以看到不会出现死锁情况。

死锁定位分析解决

主要有三步:

  1. jps命令定位进程号;
  2. jstack找到死锁查看;
  3. 解决死锁问题。

jpsjava 提供的一个显示当前所有 java 进程 pid的命令。

比如上述线程池死锁的例子,我们使用 jps 来看下。

而后我们可以使用 jstack 查看 死锁情况。

注:这里我们只看 2780 进程,其他的也可以使用 jstack 看,可以发现是没有死锁信息的。

1
jstack 2780

如上图,可以看到可以找到死锁信息。

而后就是解决死锁,快速方法是保留死锁信息后,找到其中一个死锁资源占用线程杀掉,但死锁的出现一般都是程序问题,需要认真分析程序出现死锁的情况并解决掉。

Synchronized和CAS区别

CAS

简介

CAS 采用的是一种乐观锁机制。它是使用硬件特性来保证原子性。在底层调用unsafe.compareAndSwapObject方法实现原子操作,当然该方法内部也是一个硬件加锁的操作。流程就是该方法会指定要修改的对象、对象的现有值和修改值。如果对象实际值跟指定现有值一致,就用修改值替换现有值。如果正在被其他线程加锁就返回修改失败。然后会有自旋检查,循环执行该原子操作,直到获取锁并执行成功。如果不限制执行次数,可能会造成死循环。

优缺点

优点

  1. 不用阻塞线程,并让线程在用户态和内核态直接切换,省去切换的时间,一次阻塞和唤醒操作就需要两次切换。

缺点

  1. 如果锁一直被其他线程占用,并且自旋操作没有设置最大次数,就会造成死循环,造成 CPU 占用过高。
  2. 只能保证某一个对象的原子性,并不能保证几个对象或一个线程同步操作。

应用

java.util.concurrent下的很多类,比如 Atomic开头的原子操作变量,ReentrantLock,都是根据 CAS 实现的。

学习资料

  1. CAS详解

Synchronized

简介

synchronized 采用的是 CPU的悲观锁机制。
即线程获得的是独占锁,意味着其他线程只能依靠阻塞等待线程释放锁。在 CPU 进行转换线程阻塞会引起线程上下文的切换,如果线程很多发生竞争的时候,CPU来回上下文切换会导致效率很低。在 Java 1.6synchronized 进行了优化,锁的状态:无锁,偏向锁,轻量级锁,重量级锁。但是最后转变为重量级锁,性能依然很低。

优缺点

优点

  1. 在并发非常高的情况下,CAS 操作失败率会非常高,这时候可以采用 synchronized.
  2. 由于优化后的 synchronized 引入偏向锁、轻量锁(CAS、自旋)等,而且可以通过参数灵活控制偏向锁开启与否,轻量锁的最大自旋次数等参数,导致 synchronized 可以根据并发情况及特点灵活参数配置,适应范围更广。

缺点

  1. 升级为重量锁后,阻塞(挂起线程/恢复线程)需要转入内核态完成,性能很低。

应用

太多了,略。

学习资料

  1. Java synchronized锁机制

区别总结

  1. CAS 是乐观锁,synchronized 可以称得上是悲观锁。
  2. 优化后的 synchronized 的轻量锁部分类似于 CAS(其中也使用到了 CAS)。
  3. synchronized 会有一个锁升级的过程,且不可逆,也有设置偏向锁、轻量锁及自旋次数等配置参数;CAS正常只需要指定自旋次数。
  4. CAS 操作不会阻塞线程,但使用不当会造成 CPU资源浪费;synchronized 升级为重量锁后会阻塞线程,导致性能降低。
  5. 并发不高的情况下可以考虑使用 CAS;当数据并发量很高,应当考虑使用 synchronized

总结

通过上述文章,我们了解到了 Java 中关于锁的一些内容。




-------------文章结束啦 ~\(≧▽≦)/~ 感谢您的阅读-------------

您的支持就是我创作的动力!

欢迎关注我的其它发布渠道