Java并发编程:自己动手写一把可重入锁
关于线程安全的例子,我前面的文章Java并发编程:线程安全和ThreadLocal里面提到了,简而言之就是多个线程在同时访问或修改公共资源的时候,由于不同线程抢占公共资源而导致的结果不确定性,就是在并发编程中经常要考虑的线程安全问题。前面的做法是使用同步语句synchronized来隐式加锁,现在我们尝试来用Lock显式加锁来解决线程安全的问题,先来看一下Lock接口的定义:
public interface Lock
1
Lock接口有几个重要的方法:
//获取锁,如果锁不可用,出于线程调度目的,将禁用当前线程,并且在获得锁之前,该线程将一直处于休眠状态。
void lock()
//释放锁,
void unlock()
1
2
3
4
lock()和unlock()是Lock接口的两个重要方法,下面的案例将会使用到它俩。Lock是一个接口,实现它的子类包括:可重入锁:ReentrantLock, 读写锁中的只读锁:ReentrantReadWriteLock.ReadLock和读写锁中的只写锁:ReentrantReadWriteLock.WriteLock 。我们先来用一用ReentrantLock可重入锁来解决线程安全问题,如何还不明白什么是线程安全的同学可以回头看我文章开头给的链接文章。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class MyThread implements Runnable {
private int number = 5; //公共变量,5个线程都会访问和修改该变量
private Lock lock = new ReentrantLock(); //可重入锁
@Override
public void run() {
lock.lock(); //进方法的第一件事就是锁住该方法,不能让其他线程进来
try {
number--;
System.out.println("线程 : " + Thread.currentThread().getName() + "获取到了公共资源,number = " + number);
Thread.sleep((long)(Math.random()*1000));
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); //释放锁
}
}
public static void main(String[] args) {
//起5个线程
MyThread mt = new MyThread();
Thread t1 = new Thread(mt, "t1");
Thread t2 = new Thread(mt, "t2");
Thread t3 = new Thread(mt, "t3");
Thread t4 = new Thread(mt, "t4");
Thread t5 = new Thread(mt, "t5");
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
}
}
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
控制台输出:
线程 : t1获取到了公共资源,number = 4
线程 : t2获取到了公共资源,number = 3
线程 : t3获取到了公共资源,number = 2
线程 : t4获取到了公共资源,number = 1
线程 : t5获取到了公共资源,number = 0
1
2
3
4
5
程序中创建了一把锁,一个公共变量的资源,和5个线程,每起一个线程就会对公共资源number做自减操作,从上面的输出可以看到程序中的5个线程对number的操作得到正确的结果。需要注意的是,在你加锁的代码块的finaly语句一定要释放锁,就是调用一下lock的unlock()方法。
现在来看一下什么是可重入锁 ,可重入锁就是同一个线程多次尝试进入同步代码块的时候,能够顺利的进去并执行。实例代码如下:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class MyThread implements Runnable {
private int number = 5; //公共变量,5个线程都会访问和修改该变量
private Lock lock = new ReentrantLock(); //可重入锁
public void sayHello(String threadName) {
lock.lock();
System.out.println("Hello!线程: " + threadName);
lock.unlock();
}
@Override
public void run() {
lock.lock(); //进方法的第一件事就是锁住该方法,不能让其他线程进来
try {
number--;
System.out.println("线程 : " + Thread.currentThread().getName() + "获取到了公共资源,number = " + number);
Thread.sleep((long)(Math.random()*1000));
sayHello(Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); //释放锁
}
}
public static void main(String[] args) {
//起5个线程
MyThread mt = new MyThread();
Thread t1 = new Thread(mt, "t1");
Thread t2 = new Thread(mt, "t2");
Thread t3 = new Thread(mt, "t3");
Thread t4 = new Thread(mt, "t4");
Thread t5 = new Thread(mt, "t5");
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
}
}
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
上述代码什么意思呢?意思是每起一个线程的时候,线程运行run方法的时候,需要去调用sayHello()方法,那个sayHello()也是一个需要同步的和保证安全的方法,方法的第一行代码一来就给方法上锁,然后做完自己的工作之后再释放锁,工作期间,禁止其他线程进来,除了本线程除外。上面代码输出:
线程 : t1获取到了公共资源,number = 4
Hello!线程: t1
线程 : t2获取到了公共资源,number = 3
Hello!线程: t2
线程 : t3获取到了公共资源,number = 2
Hello!线程: t3
线程 : t4获取到了公共资源,number = 1
Hello!线程: t4
线程 : t5获取到了公共资源,number = 0
Hello!线程: t5
1
2
3
4
5
6
7
8
9
10
实现一把简单的锁
如果你明白了上面几个例子是用来干嘛的,好,我们可以继续进行下去了,我们来实现一把最简单的锁。先不考虑这把锁的公平性和可重入性,只要求达到当使用这把锁的时候我们的代码快安全即可。
我们先来定义自己的一把锁MyLock。
public class MyLock implements Lock {
@Override
public void lock() {
}
@Override
public void lockInterruptibly() throws InterruptedException {
}
@Override
public boolean tryLock() {
return false;
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return false;
}
@Override
public void unlock() {
}
@Override
public Condition newCondition() {
return null;
}
}
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
定义自己的锁需要实现Lock接口,而上面是Lock接口需要实现的方法,我们抛开其他因素,只看lock()和unlock()方法。
public class MyLock implements Lock {
private boolean isLocked = false; //定义一个变量,标记锁是否被使用
@Override
public synchronized void lock() {
while(isLocked) { //不断的重复判断,isLocked是否被使用,如果已经被占用,则让新进来想尝试获取锁的线程等待,直到被正在运行的线程唤醒
try {
wait();
}catch (InterruptedException e) {
e.printStackTrace();
}
}
//进入该代码块有两种情况:
// 1.第一个线程进来,此时isLocked变量的值为false,线程没有进入while循环体里面
// 2.线程进入那个循环体里面,调用了wait()方法并经历了等待阶段,现在已经被另一个线程唤醒,
// 唤醒它的线程将那个变量isLocked设置为true,该线程才跳出了while循环体
//跳出while循环体,本线程做的第一件事就是赶紧占用线程,并告诉其他线程说:嘿,哥们,我占用了,你必须等待
isLocked = true; //将isLocked变量设置为true,表示本线程已经占用
}
@Override
public void lockInterruptibly() throws InterruptedException {
}
@Override
public boolean tryLock() {
return false;
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return false;
}
@Override
public synchronized void unlock() {
//线程释放锁,释放锁的过程分为两步
//1. 将标志变量设置为true,告诉其他线程,你可以占用了,不必死循环了
//2. 唤醒正在等待中的线程,让他们去强制资源
isLocked = false;
notifyAll(); //通知所有等待的线程,谁抢到我不管
}
@Override
public Condition newCondition() {
return null;
}
}
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
48
49
50
51
从上面代码可以看到,这把锁还是照样用到了同步语句synchronized,只是同步的过程我们自己来实现,用户只需要调用我们的锁上锁和释放锁就行了。其核心思想是用一个公共变量isLocked来标志当前锁是否被占用,如果被占用则当前线程等待,然后每被唤醒一次就尝试去抢那把锁一次(处于等待状态的线程不止当前线程一个),这是lock方法里面使用那个while循环的原因。当线程释放锁时,首先将isLocked变量置为false,表示锁没有被占用,其实线程可以使用了,并调用notifyAll()方法唤醒正在等待的线程,至于谁抢到我不管,不是本宝宝份内的事。
那么上面我们实现的锁是不是一把可重入的锁呢?我们来调用sayHello()方法看看:
import java.util.concurrent.locks.Lock;
public class MyThread implements Runnable {
private int number = 5; //公共变量,5个线程都会访问和修改该变量
private Lock lock = new MyLock(); //创建一把自己的锁
public void sayHello(String threadName) {
System.out.println(Thread.currentThread().getName() + "线程进来,需要占用锁");
lock.lock();
System.out.println("Hello!线程: " + threadName);
lock.unlock();
}
@Override
public void run() {
lock.lock(); //进方法的第一件事就是锁住该方法,不能让其他线程进来
try {
number--;
System.out.println("线程 : " + Thread.currentThread().getName() + "获取到了公共资源,number = " + number);
Thread.sleep((long)(Math.random()*1000));
sayHello(Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); //释放锁
}
}
public static void main(String[] args) {
//起5个线程
MyThread mt = new MyThread();
Thread t1 = new Thread(mt, "t1");
Thread t2 = new Thread(mt, "t2");
Thread t3 = new Thread(mt, "t3");
Thread t4 = new Thread(mt, "t4");
Thread t5 = new Thread(mt, "t5");
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
}
}
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
为了特意演示效果,我在sayHello方法加锁之前打印一下当前线程的名称,现在控制台输出如下:
线程 : t1获取到了公共资源,number = 4
t1线程进来,需要占用锁
1
2
如上所述,t1线程启动并对公共变量做自减的时候,调用了sayHello方法。同一个线程t1,在线程启动的时候获得过一次锁,再在调用sayHello也想要获取这把锁,这样的需求我们是可以理解的,毕竟sayHello方法也时候也需要达到线程安全效果嘛。可问题是痛一个线程尝试获取锁两次,程序就被卡住了,t1在run方法的时候获得过锁,在sayHello方法想再次获得锁的时候被告诉说:唉,哥们,该锁被使用了,至于谁在使用我不管(虽然正在使用该锁线程就是我自己),你还是等等吧!所以导致结果就是sayHello处于等待状态,而run方法则等待sayHello执行完。控制台则一直处于运行状态。
如果你不理解什么是可重入锁和不可重入锁,对比一下上面使用MyLock的例子和使用J.U.C.包下的ReentrantLock俩例子的区别,ReentrantLock是可重入的,而MyLock是不可重入的。
实现一把可重入锁
现在我们来改装一下这把锁,让他变成可重入锁,也就是说:如果我已经获得了该锁并且还没释放,我想再进来几次都行。核心思路是:用一个线程标记变量记录当前正在执行的线程,如果当前想尝试获得锁的线程等于正在执行的线程,则获取锁成功。此外还需要用一个计数器来记录一下本线程进来过多少次,因为如果同步方法调用unlock()时,我不一定就要释放锁,只有本线程的所有加锁方法都释放锁的时候我才真正的释放锁,计数器就起到这个功能。
改装过后的代码如下:
public class MyLock implements Lock {
private boolean isLocked = false; //定义一个变量,标记锁是否被使用
private Thread runningThread = null; //第一次线程进来的时候,正在运行的线程为null
private int count = 0; //计数器
@Override
public synchronized void lock() {
Thread currentThread = Thread.currentThread();
//不断的重复判断,isLocked是否被使用,如果已经被占用,则让新进来想尝试获取锁的线程等待,直到被正在运行的线程唤醒
//除了判断当前锁是否被占用之外,还要判断正在占用该锁的是不是本线程自己
while(isLocked && currentThread != runningThread) { //如果锁已经被占用,而占用者又是自己,则不进入while循环
try {
wait();
}catch (InterruptedException e) {
e.printStackTrace();
}
}
//进入该代码块有三种情况:
// 1.第一个线程进来,此时isLocked变量的值为false,线程没有进入while循环体里面
// 2.线程进入那个循环体里面,调用了wait()方法并经历了等待阶段,现在已经被另一个线程唤醒,
// 3.线程不是第一次进来,但是新进来的线程就是正在运行的线程,则直接来到这个代码块
// 唤醒它的线程将那个变量isLocked设置为true,该线程才跳出了while循环体
//跳出while循环体,本线程做的第一件事就是赶紧占用线程,并告诉其他线程说:嘿,哥们,我占用了,你必须等待,计数器+1,并设置runningThread的值
isLocked = true; //将isLocked变量设置为true,表示本线程已经占用
runningThread = currentThread; //给正在运行的线程变量赋值
count++; //计数器自增
}
@Override
public void lockInterruptibly() throws InterruptedException {
}
@Override
public boolean tryLock() {
return false;
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return false;
}
@Override
public synchronized void unlock() {
//线程释放锁,释放锁的过程分为三步
//1. 判断发出释放锁的请求是否是当前线程
//2. 判断计数器是否归零,也就是说,判断本线程自己进来了多少次,是不是全释放锁了
//3. 还原标志变量
if(runningThread == Thread.currentThread()) {
count--;//计数器自减
if(count == 0) { //判断是否归零
isLocked = false; //将锁的状态标志为未占用
runningThread = null; //既然已经真正释放了锁,正在运行的线程则为null
notifyAll(); //通知所有等待的线程,谁抢到我不管
}
}
}
@Override
public Condition newCondition() {
return null;
}
}
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
如代码注释所述,这里新增了两个变量runningThread和count,用于记录当前正在执行的线程和当前线程获得锁的次数。代码的关键点在于while循环判断测试获得锁的线程的条件,之前是只要锁被占用就让进来的线程等待,现在的做法是,如果锁已经被占用,则判断一下正在占用这把锁的就是我自己,如果是,则获得锁,计数器+1;如果不是,则新进来的线程进入等待。相应的,当线程调用unlock()释放锁的时候,并不是立马就释放该锁,而是判断当前线程还有没有其他方法还在占用锁,如果有,除了让计数器减1之外什么事都别干,让最后一个释放锁的方法来做最后的清除工作,当计数器归零时,才表示真正的释放锁。
我知道你在怀疑这把被改造过后的锁是不是能满足我们的需求,现在就让我们来运行一下程序,控制台输出如下:
线程 : t1获取到了公共资源,number = 4
t1线程进来,需要占用锁
Hello!线程: t1
线程 : t5获取到了公共资源,number = 3
t5线程进来,需要占用锁
Hello!线程: t5
线程 : t2获取到了公共资源,number = 2
t2线程进来,需要占用锁
Hello!线程: t2
线程 : t4获取到了公共资源,number = 1
t4线程进来,需要占用锁
Hello!线程: t4
线程 : t3获取到了公共资源,number = 0
t3线程进来,需要占用锁
Hello!线程: t3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
嗯,没错,这就是我们想要的结果。
好了,自己动手写一把可重入锁就先写到这了,后面有时间再写一篇用AQS实现的可重入锁,毕竟ReentrantLock这哥们就是用AQS实现的可重入锁,至于什么是AQS以及如何用AQS实现一把可重入锁,且听我慢慢道来。如果你看懂这篇文章的思路或者如果是你看完了这篇文章有动手写一把可重入锁的冲动,麻烦点个赞哦,毕竟大半夜的写文章挺累的,是吧?