原创:花括号MC(微信公众号:huakuohao-mc)。关注JAVA基础编程及大数据,注重经验分享及个人成长。
这是并发编程系列的第二篇文章。上一篇介绍了线程和任务的关系,以及如何创建线程。这篇说一下多线程如何正确的访问共享可变资源。
所谓的共享可变资源就是每个线程都可以读也都可以写的资源。如何让多个线程正确的修改以及读取共享变量是一门学问。
问题引入
如下段代码实现了一个线程计数器功能,也就是统计一下有多少个线程执行了任务。
首先定义一个任务1
2
3
4
5
6
7
8
9
10public class Task implements Runnable {
public static int count = 0;
public void increase(){
count++;
}
public void run() {
increase();
}
}
使用线程驱动任务1
2
3
4
5
6
7
8
9public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10000; i++){
Thread thread = new Thread(new Task());
thread.start();
}
//等待子线程执行结束
Thread.sleep(5000);
System.out.println(Task.count);
}
上面的示例代码,如果你需要多执行几次,就会发现得到的结果是不一样的,可见并发程序的错误是多么隐蔽。我在本地环境拿到的结果为1W或者9999,如下所示:
问题分析
理论上,我们启动了1W
个线程,但结果却有可能是9999
。这个问题的原因有两个,第一个原因是两个线程可以同时修改和读取变量count
。第二个原因是在java
里面的自增++
操作不是原子操作。
当执行count++
操作的时候,实际上是三个动作,先读取count
的当前值,然后将count
加1
,最后将结果写入count
。
假设第一个线程读取count
之后,得到count
的值为0
,然后执行自增操作的过程中,第二个线程也来读取count
的值,那么第二个线程得到的值还是0
,然后第二个线程也基于0
做自增操作,这样两个线程执行完得到的结果都是1
,并不是2
。
解决方案
其实上面的问题可以概括为“多线程如何正确的使用共享可变资源”的问题,这也是并发编程最为核心的问题。对于这个问题,通常有两种解决方案。
第一种方案就是对共享资源进行加锁同步处理,锁可以保证同一时刻只有一个线程在使用共享资源;
第二种方案就是不共享变量,比如每个线程都持有一份共享变量的Copy,或者只有一个线程可以修改共享变量,其他线程只读。
锁的介绍
当我们对一个资源或者一段代码进行加锁处理的时候,表示同一时刻只有一个线程可以执行该段代码,当一个线程执行完并释放锁资源之后,其他线程才有机会获取该资源继续执行。
这个过程好比多个人在争抢一个卫生间的坑位,当卫生间被你抢到之后,立刻把卫生间锁住,这样其他人就没办法影响你使用了,如果你不加锁,就会很多人不断的把门拉开,对你产生影响。
锁分类
从使用方式来看,Java
提供了两种锁,第一种锁称为内制锁也就是大家熟悉的synchronized
。第二种锁称为显示锁也就是ReentrantLock
内置锁-synchronized
我们可以通过使用synchronized
给increase()
方法进行加锁同步处理,这样可以保证同一时刻只有一个线程使用共享资源count
。1
2
3public synchronized void increase(){
count++;
}
在java
中每个对象或者类都含有一个单一的内置锁,也叫做监视器锁。线程进入同步代码块时会自动获取锁,离开时会自动释放锁。
如果一个对象中有多个方法都是加锁的,那么他们共享同一把锁,假设一个对象包含 public synchronized void f()
方法,以及public synchronized void g();
方法,如果某个线程调用了f()
,那么其他线程必须等f()
结束并释放锁之后,才能继续调用f()
或者g()
。
内置锁的重入
一个线程想获取一个由其他线程持有的锁时会发生阻塞,但是一个线程可以重新获得由他自己持有的锁。比如一个子类改写父类的synchronized
修饰的方法,然后再次调用父类中的方法,如果没有锁重入机制,那么将发生死锁。1
2
3
4
5
6
7
8
9
10
11
12public class Parent{
public synchronized void doSomething(){
//do something..
}
}
public class Children extends Parent{
public synchronized void doSomething(){
// children do something
super.doSomething();
}
}
临界区
除了上面的锁住整个方法以外,还可以锁住部分代码块。这被称为同步控制块,也叫临界区。这样做的目的可以显著提高程序性能,因为缩小了锁粒度。1
2
3
4//和锁住方法的效果是一样的,但是缩小了锁粒度
synchronized(synObject){
//do something
}
显示锁-ReentrantLock
对于上面的任务计数器代码,除了内置锁以外,还可以使用显示锁ReentrantLock
来实现。示例代码如下
1 | public class ReentranLockTask implements Runnable { |
对于显示的锁,在上面的代码量明显比内制锁要多,因为显示锁除了要自己声明锁以外,还要自己手动释放锁,如果忘记释放锁,那将会是灾难的。
但是显示锁也有自己的特点,比如更加灵活,你可以在发生异常时,清理线程资源。但是如果是内制锁,你能做的恐怕就不多了。
除此之外,使用显示锁对资源进行获取时,可以指定时间范围,比如通过tryLock(long timeout, TimeUnit unit)
方法,如果在指定时间内没有获取,线程可以去执行一些其他事情,不用长时间处于阻塞状态。
显示锁-读写分离锁
从名字可以看出这是两把锁,一个是读锁,一个是写锁。读写锁允许多个读线程同时执行,但是当有写线程操作的时候还是只有一个线程可以操作。
读写锁在读多写少的情况下,可以显著提高性能,因为多个读操作时并行执行。
一个典型的读多写少的应用场景就是缓存。下面的代码示例分别使用显示锁和读写分离锁来实现两个不同的缓存。可以明显感受到两个缓存的性能区别。
抽象类 DemoCache
,定义了缓存的基本操作,显示锁实现的缓存和读写分离锁实现的缓存都继承自该类。
1 | public abstract class DemoCache { |
DemoLockCache
,使用显示锁实现的缓存,性能比较差。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
29public class DemoLockCache extends DemoCache {
//显示锁,也可以使用synchronized
private ReentrantLock lock = new ReentrantLock();
//缓存Map
private Map<String,String> cacheMap = new HashMap<String,String>();
String read(String key) throws Exception {
lock.lock();
try{
String value = cacheMap.get(key);
Thread.sleep(500);
return value;
}finally {
lock.unlock();
}
}
void write(String key, String value) throws Exception {
lock.lock();
try{
cacheMap.put(key,value);
Thread.sleep(300);
}finally {
lock.unlock();
}
}
}
DemoReadWriteLockCache
, 使用读写锁实现的缓存,性能较好。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
33public class DemoReadWriteLockCache extends DemoCache {
//读写分离锁
private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//缓存Map
private Map<String, String> cacheMap = new HashMap<String, String>();
String read(String key) throws InterruptedException {
//读锁
Lock readLock = readWriteLock.readLock();
readLock.lock();
try {
String value = cacheMap.get(key);
Thread.sleep(500);
return value;
} finally {
readLock.unlock();
}
}
void write(String key, String value) throws InterruptedException {
//写锁
Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
cacheMap.put(key, value);
Thread.sleep(300);
} finally {
writeLock.unlock();
}
}
}
创建两个任务,一个用于读操作,一个用于写操作。
用于读操作的DemoCacheReadTask
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class DemoCacheReadTask implements Runnable {
private DemoCache demoCache;
public DemoCacheReadTask(DemoCache demoCache){
this.demoCache = demoCache;
}
public void run() {
String key = Thread.currentThread().getName();
try {
demoCache.read(key);
} catch (Exception e) {
e.printStackTrace();
}
}
}
用于写操作的DemoCacheWriteTask
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public class DemoCacheWriteTask implements Runnable {
private DemoCache demoCache;
public DemoCacheWriteTask(DemoCache demoCache){
this.demoCache = demoCache;
}
public void run() {
String key = Thread.currentThread().getName();
String value = key + "value";
try {
demoCache.write(key,value);
} catch (Exception e) {
e.printStackTrace();
}
}
}
测试类DemoCacheTest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class DemoCacheTest {
public static void main(String[] args){
//非读写分离实现的缓存
//DemoCache demoCache = new DemoLockCache();
//读写分离实现的缓存
DemoCache demoCache = new DemoReadWriteLockCache();
//读线程
for (int i = 0; i < 10; i++){
Thread thread = new Thread(new DemoCacheReadTask(demoCache));
thread.start();
}
//写线程
for (int i = 0; i < 3; i++){
Thread thread = new Thread(new DemoCacheWriteTask(demoCache));
thread.start();
}
}
}
结束
这篇文章主要介绍了,如何通过加锁的方式,实现共享可变资源的正确访问。其中包括内置锁,显示锁,读写锁。在一般情况下建议大家使用内置锁,如果内置锁不能满足要求可以考虑使用显示锁,但一定不要忘记手动释放锁。在读多写少的场景,可以考虑使用读写分离锁提高性能。
下一篇介绍不使用锁的情况下,如何做到正确的访问共享可变资源。
推荐阅读:
1. Java并发编程那些事儿(一) ——任务与线程
2. Java8的Stream流真香,没体验过的永远不知道
3. Awk这件上古神兵你会用了吗
4. 手把手教你搭建一套ELK日志搜索运维平台