一、synchronized的优化手段
1.1锁膨胀/升级
前面我们说过synchronized关键字加的锁既是轻量级锁也是重量级锁,它是根据实际情况自适应加锁的,这种自适应是基于锁膨胀或者说是锁升级这样的优化手段来实现的。
锁升级过程:
- 当没有线程加锁的时候,此时为无锁状态。
- 当首个线程进行加锁的时候,此时进入偏向锁的状态,偏向锁不是真的加锁,而是在对象头做个标记而已,
- 当有其他线程进行加锁,导致产生了锁竞争时,此时进入轻量级锁状态。
- 如果竞争进一步加剧,进入重量级锁状态。
像上面根据锁竞争的程度来逐步升级锁的情况,就是锁的膨胀或者称为锁的升级。
1.2锁粗化
所谓锁粗化就是将synchronized
的加锁代码块范围增大,加锁的代码块中的内容越多,锁就越粗,否则锁就越细。
一般我们认为,锁越细,多线程间的并发性越高,锁越粗,加锁解锁的开销就会更小。编译器会对你加的锁做一个优化,如果编译器判定加的锁过细,就会自动粗化,从而提高程序运行效率。
1.3锁消除
有些代码,编译器认为没有加锁的必要,就会自动把你加的锁自动去除,像类似这样的优化,就是锁消除。
二、java中的JUC
java中的JUC就是来自java.util.concurrent
包下的一些标准类或者接口,都是有关并发或者有关多线程的一些类和接口。
2.1Callable接口
前面我们创建线程的时候,有两种方式,一是继承Thred
类并重写run
方法来创建线程,二是通过Runnable
接口来创建线程,除上述两种方式,我们还可以通过Callable
接口配合FutureTask
类来创建线程,使用该方法创建线程能够支持带返回值的任务,而最开始的那两种方法是不支持带回返回值的。
其中通过实现Callable
接口的call
方法来描述带有返回值的任务,FutureTask
就是对于具体的Runnable
或者Callable
任务的执行结果进行取消、查询是否完成、获取返回值。必要时可以通过get
方法获取执行结果(返回值),如果任务还没有执行完毕,该方法会阻塞直到任务返回结果。
在创建线程的时候,传入的引用不能是Callable
类型,而应该是FutrueTask
类型,根据Thread
的构造方法,传入的任务类型需是Runnable
类,Callable
与Runnable
没有关系,而FutrueTask
类实现了Runnable
类,所以在此之前我们需要把实现Callable
接口的对象引用传给FutrueTask
类的实例对象。
综上,Callable
用来描述任务,FutureTask
类用来管理Callable
任务的执行结果。
比如,现在我们需要使用线程来计算一个值,并通过返回值的方式获取执行结果。
参考代码:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return 100 * (1 + 100) / 2;
}
};
FutureTask<Integer> task = new FutureTask<>(callable);
Thread thread = new Thread(task);
thread.start();
//获取执行结果
System.out.println(task.get());
}
}
运行结果:
5050
Process finished with exit code 0
2.2ReentrantLock类(可重入锁)
ReentrantLock
其实就是可重入锁,使用方式是通过lock
方法加锁,unlock
方法解锁,注意加锁和解锁两个过程是分开的,而synchronized
关键字加锁解锁是一步到位的。
由于加锁解锁两个操作是分开的,相比于加锁解锁一体化这就很容易造成死锁问题,这是因为一方面加锁后容易忘记去解锁,造成死锁,另一方面加锁后解锁前中间的代码万一出了问题,可能会导致解锁无法正常执行导致解锁失败,造成死锁。
所以使用ReentrantLock
类时,一般要搭配finally
使用。
ReentrantLock lock = new ReentrantLock();
//dosomething
lock.lock();
try {
// working
} finally {
lock.unlock()
}
ReentrantLock
类与synchronized
关键字区别:
-
ReentrantLock
是一个java标准类,是使用java代码实现的,synchronized
是一个关键字,是基于JVM内部实现的,是C/C++代码。 -
ReentrantLock
需要手动解锁,需谨防忘记解锁,而synchronized
加锁解锁一体化,不需要手动解锁。 - 如果出现锁竞争,
ReentrantLock
竞争失败时可以阻塞等待,也可以通过trylock方法直接返回退出,而synchronized
竞争失败时只能阻塞等待。 -
ReentrantLock
构造实例对象时,可以指定fair参数来决定该锁对象是公平锁还非公平锁,synchronized
加的锁是非公平锁,不能指定为公平锁。 -
ReentrantLock
类衍生出的等待机制是Condition
类,synchronized
关键字衍生的等待机制是wait/notify
等待机制。
2.3Semaphore类(信号量)
这个概念比较抽象,我们来打个比方,有个停车场,停车场门口有一个灯牌,会显示停车位还剩余多少个,每进去一辆车,显示的停车位数量就减一,每出去一辆出,显示的停车位数量就加一。
上面显示停车位数量的灯牌其实就是信号量,信号量是一更加广义的锁,描述了可用资源的个数。
每次申请一个可用资源,信号量中的计数器就减一(P操作)。
每次释放一个可用资源,信号量中的计数器就加一(V操作)。
当可用资源数量为0时,再次进行P操作,会陷入阻塞等待状态。
锁我们可以理解为“二元信号量”,因为计数器的取值不是0就是1,它的可用资源就一个。
Semaphore类的常用方法:
序号 | 方法 | 方法类型 | 作用 |
---|---|---|---|
1 | public Semaphore(int permits) | 构造方法 | 构造可用资源为permits个的信号量对象 |
2 | public Semaphore(int permits, boolean fair) | 构造方法 | 相比于方法1,该构造方法还能指定信号量是否是公平性质的 |
3 | public void acquire() throws InterruptedException | 普通方法 | 申请可用资源 |
4 | public void release() | 普通方法 | 释放可用资源 |
代码演示:
import java.util.concurrent.Semaphore;
public class Main {
public static void main(String[] args) throws InterruptedException {
//构造方法中的permits参数表示可用资源的个数
Semaphore semaphore = new Semaphore(4);
//每次使用一个可用资源,信号量就会减少1
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
//此时可用资源为0,线程进入阻塞,需要使用release方法释放资源,线程才能继续执行
semaphore.release();
System.out.println("释放成功");
semaphore.acquire();
System.out.println("申请成功");
}
}
执行结果:
申请成功
申请成功
申请成功
申请成功
释放成功
申请成功
Process finished with exit code 0
2.4CountDownLatch同步工具类
CountDownLatch是一个同步工具类,它允许一个或多个线程一直等待,直到其他线程执行完后再执行。
打个比方,假设有一场跑步比赛,一个有5个远动员参赛,只有当最后一个远动员冲过终点线时,裁判才能宣布比赛结束。
这里的运动员就相当于线程,裁判就相当于CountDownLatch
类。
CountDownLatch同步工具类常用方法:
序号 | 方法 | 方法类型 | 作用 |
---|---|---|---|
1 | public CountDownLatch(int count) | 构造方法 | 构造实例对象,count表示CountDownLatch对象中计数器的值 |
2 | public void await() throws InterruptedException | 普通方法 | 使所处的线程进入阻塞等待,直到计数器的值清零 |
3 | public void countDown() | 普通方法 | 将计数器的值减1 |
4 | public long getCount() | 普通方法 | 获取计数器最初的值 |
使用方式:
- 创建
CountDownLatch
对象,并初始化计数器的值。 - 在每个线程执行的最后使用
countDown
方法,表示当前线程执行完毕,计数器的值减1。 - 在主线程中使用
await
方法,等待CountDownLatch
对象的计数器清零,表示所管理的线程全部执行完毕,起到线程同步的作用。
参考代码:
import java.util.concurrent.*;
public class Main {
public static final int COUNT = 5;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(COUNT);
for (int i = 0; i < COUNT; i++) {
Thread thread = new Thread(() -> {
try {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + "任务执行完毕!");
countDownLatch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
}
//等待计数器清零,清零前,线程处于阻塞等待状态,清零后,即全部任务执行完毕
countDownLatch.await();
System.out.println("任务全部完成!");
}
}
这样的场景在实际开发当中,也是很常见的,比如要下载一个较大的文件的时候,常常将文件拆分,使用多线程并发下载。
而在这样一个场景中,需要等待最后一个线程也下载完毕,才能说整个文件下载完毕,也就是使用CountDownLatch对象进行计数,等计数器清零了await
方法就会返回,表示文件下载完成。
2.5有关数据结构的线程安全类
2.5.1多线程使用顺序表
ArrayList在多线程中是线程不安全的,多线程环境中使用基于写实拷贝实现的CopyOnWriteArrayList。
所谓写实拷贝,就是写的时候会创建一个副本,再副本上进行修改,同时如果存在读操作会在原文件数进行查询,等修改完毕后就会将副本“转正”。
2.5.2多线程使用队列
多线程情况下常常使用阻塞队列:
- ArrayBlockingQueue 基于数组实现的阻塞队列
- LinkedBlockingQueue 基于链表实现的阻塞队列
- PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
- TransferQueue 最多只包含一个元素的阻塞队列
2.5.3多线程使用哈希表
HashMap本身是线程不安全的,将HashMap中的重要方法使用synchornized
加锁后,就得到了HashTable类,虽然HashTable类是线程安全的,但是由于是对方法进行无脑加锁,本质加锁的对象是HashTable类的实例对象,这样就会导致锁竞争概率加大,就相当于公司里所有的员工需要请假时都需要找老板签字批准,这样会导致老板非常地忙,这个老板就相当于加锁的哈希表对象,最终会造成哈希表的效率下降。
为了解决这个问题,java提供了ConcurrentHashMap类,该类是基于哈希表中的每一个链表对象进行加锁,线程需要对哪个链表对象进行操作,就在哪里加锁,由于哈希表中链表数量很多,链表对象的元素个数较少,可以有效地降低锁竞争的概率,相当于公司中的老板将权力下放给各个部门,员工请假时只需向所在的部门领导请假即可。
到这里,Java多线程有关内容基本上都介绍完毕了,不知道小伙伴们学会了多少呢?
作者:未见花闻
链接:https://juejin.cn/post/7106118341970493476
来源:稀土掘金
网友评论