Java多线程与并发-基础篇
1.线程池
首先简单来介绍一下Executor。Executor 框架是 jdk1.5 中引入的,其内部使用了线程池机制,它在 java.util.cocurrent 包下,通过该框架来控制线程的启动、执行和关闭,可以简化并发编程的操作。Executor 框架包括:Executor,Executors,ExecutorService,ThreadPoolExecutor,CompletionService,Future,Callable 等。
Executors 提供了一系列工厂方法用于创先线程池,返回的线程池都实现了 ExecutorService 接口。
- newCachedThreadPool():创建一个可缓存的线程池。先查看池中有没有以前建立的线程,如果有,就使用;如果没有,就建一个新的线程加入池中。能重复使用的线程,必须是闲置有效期内的池中线程,缺省 timeout 是 60s,超过这个时长,线程实例将被终止及移出池。
- newFixedThreadPool(int nThreads):创建固定数目线程的线程池。任意时间点,最多只能有固定数目的活动线程存在,此时如果有新的线程要建立,只能放在另外的队列中等待,直到当前的线程中某个线程终止直接被移出池子。
- newScheduledThreadPool(int corePoolSize):创建一个支持定时及周期性的任务执行的线程池。这个池子里的线程可以按 schedule 依次 延期执行,或周期执行。
- SingleThreadExecutor():单例线程,任意时间池中只能有一个线程。
Executor执行Runnable任务
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
executorService.execute(()->
System.out.println(Thread.currentThread().getName());
}
executorService.shutdown();
}
Executor 执行Callable任务
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
List> resultList = new ArrayList>();
for (int i = 0; i < 5; i++) {
Future future = executorService.submit(()->
Thread.currentThread().getName());
resultList.add(future);
}
for (Future fs : resultList){
try{
//Future返回如果没有完成,则一直循环等待,直到Future返回完成
while(!fs.isDone());
System.out.println(fs.get());
}catch(InterruptedException|ExecutionException e){
e.printStackTrace();
}finally{
//启动一次顺序关闭,执行以前提交的任务,但不接受新任务
executorService.shutdown();
}
}
}
自定义线程池
可以用 ThreadPoolExecutor 类创建,它有4个构造方法来创建线程池,用该类很容易实现自定义的线程池。
public static void main(String[] args){
//创建等待队列
BlockingQueue bqueue = new ArrayBlockingQueue(20);
//创建线程池,池中保存的线程数为3,允许的最大线程数为5,
//空闲线程有效期50毫秒
ThreadPoolExecutor pool = new ThreadPoolExecutor(3,5,50,TimeUnit.MILLISECONDS,bqueue);
//创建七个任务
for (int i = 0; i < 7; i++) {
pool.execute(()->{
System.out.println(Thread.currentThread().getName() + "正在执行。。。");
try{
Thread.sleep(100);
}catch(InterruptedException e){
e.printStackTrace();
}
});
}
//关闭线程池
pool.shutdown();
}
通过 excute方法将一个Runnable 任务添加到线程池中,按如下顺序执行
- 如果线程池中的线程数量少于 corePoolSize(核心线程数),即使线程池中有空闲线程,也会创建一个新的线程来执行新添加的任务。
- 如果线程池中的线程数量大于等于 corePoolSize,但workQueue(缓冲队列) 未满,则将新添加的任务放到 workQueue 中,按照 FIFO 的原则依次等待执行(线程池中有线程空闲出来后依次将缓冲队列中的任务交付给空闲的线程执行)。
- 如果线程池中的线程数量大于等于 corePoolSize,且缓冲队列 workQueue 已满,但线程池中的线程数量小于 maximumPoolSize(最大线程数),则会创建新的线程来处理被添加的任务。
4.如果线程池中的线程数量等于了 maximumPoolSize,可以构造有RejectedExecutionHandler类型参数的构造方法,来处理溢出的任务。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- AbortPolicy:如果不能接受任务了,则抛出异常。
- CallerRunsPolicy:如果不能接受任务了,则让调用的线程去完成。
- DiscardOldestPolicy:如果不能接受任务了,则丢弃最老的一个任务,由一个队列来维护。
- DiscardPolicy:如果不能接受任务了,则丢弃任务。
几种排队策略
- 直接切换 缓冲队列采用 SynchronousQueue,它将任务直接交给线程处理而不保持它们。如果不存在可用于立即运行任务的线程(即线程池中的线程都在工作),则试图把任务加入缓冲队列将会失败,因此会构造一个新的线程来处理新添加的任务,并将其加入到线程池中。直接提交通常要求无界 maximumPoolSizes(Integer.MAX_VALUE) 以避免拒绝新提交的任务。newCachedThreadPool 采用的便是这种策略。
- 无界队列 使用无界队列(典型的便是采用预定义容量的 LinkedBlockingQueue,理论上是该缓冲队列可以对无限多的任务排队)将导致在所有 corePoolSize 线程都工作的情况下将新任务加入到缓冲队列中。这样,创建的线程就不会超过 corePoolSize,也因此,maximumPoolSize 的值也就无效了。当每个任务完全独立于其他任务,即任务执行互不影响时,适合于使用无界队列。newFixedThreadPool采用的便是这种策略。
- 有边界的队列。 有限队列(例如, ArrayBlockingQueue)有助于在使用有限maxPoolSizes时防止资源耗尽,但可能更难调整和控制。 队列大小和最大池大小可能彼此交易:使用大队列和小型池可以最大限度地减少CPU使用率,OS资源和上下文切换开销,但可能导致人为的低吞吐量。 如果任务频繁阻塞(例如,如果它们是I / O绑定),则系统可能能够安排比你允许的更多线程的时间。 使用小型队列通常需要较大的池大小,这样可以使CPU繁忙,但可能会遇到不可接受的调度开销,这也降低了吞吐量。
2.倒计时器CountDownLatch
CountDownLatch用来控制一个或者多个线程等待多个线程。它维护了一个计数器 cnt,每次调用 countDown() 方法会让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒。
public static void main(String[] args) throws InterruptedException {
final int total = 10;
CountDownLatch countDownLatch = new CountDownLatch(total);
ExecutorService exe = Executors.newCachedThreadPool();
for (int i = 0; i < totalThread; i++) {
exe.execute(() -> {
System.out.print(Thread.currentThread().getName());
countDownLatch.countDown();
});
}
countDownLatch.await();
System.out.println("结束");
executorService.shutdown();
}
3.障碍器CyclicBarrier
CylicBarrier和CountDownLatch相似,也是等待某些线程都做完以后再执行。与CountDownLatch区别在于这个计数器可以反复使用。
public static void main(String[] args) {
//创建CyclicBarrier对象,
//并设置执行完一组5个线程的并发任务后,再执行最后的任务
CyclicBarrier cb = new CyclicBarrier(5, ()->
System.out.println("执行最后的任务"));
new SubTask("A", cb).start();
new SubTask("B", cb).start();
new SubTask("C", cb).start();
new SubTask("D", cb).start();
new SubTask("E", cb).start();
}
class SubTask extends Thread {
private String name;
private CyclicBarrier cb;
SubTask(String name, CyclicBarrier cb) {
this.name = name;
this.cb = cb;
}
public void run() {
System.out.println("[" + name + "] 开始执行");
for (int i = 0; i < 888888; i++) ; //耗时的任务
System.out.println("[" + name + "] 执行完毕,通知障碍器");
try {
//每执行完一项任务就通知障碍器
cb.await();
} catch (InterruptedException|BrokenBarrierException e) {
e.printStackTrace();
}
}
}
4.信号量Semaphore
Semaphore 可以控制某个资源被同时访问的任务数,它通过acquire获取一个许可,release释放一个许可。如果被同时访问的任务数已满,则其他acquire的任务进入等待状态,直到有一个任务被release掉,它才能得到许可。
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
//只允许5个线程同时访问
final Semaphore semp = new Semaphore(5);
for (int n= 0; n< 10; n++){
final int m = n;
exec.execute(()->{
try {
//获取许可
semp.acquire();
System.out.println("线程" +
Thread.currentThread().getName() + "获得许可:" + m);
//耗时的任务
for (int i = 0; i < 888888; i++) ;
//释放许可
semp.release();
System.out.println("线程" +
Thread.currentThread().getName() + "释放许可:" + m);
System.out.println("当前允许进入的任务个数:" +
semp.availablePermits());
}catch(InterruptedException e){
e.printStackTrace();
}
});
}
exec.shutdown();
}
5.ForkJoin
ForkJoin类似MapReduce算法,两者区别是:ForkJoin只有在必要时如任务非常大的情况下才分割成一个个小任务,而 MapReduce总是在开始执行第一步进行分割。可继承抽象类RecursiveAction(无返回值),RecursiveTask(有返回值)来实现。
public class TestForkJoin extends RecursiveTask {
private final int threshold = 5;
private int first;
private int last;
public TestForkJoin (int first, int last) {
this.first = first;
this.last = last;
}
@Override
protected Integer compute() {
int result = 0;
if (last - first <= threshold) {
// 任务足够小则直接计算
for (int i = first; i <= last; i++) {
result += i;
}
} else {
// 拆分成小任务
int middle = first + (last - first) / 2;
TestForkJoin leftTask = new TestForkJoin(first, middle);
TestForkJoin rightTask = new TestForkJoin(middle + 1, last);
leftTask.fork();
rightTask.fork();
result = leftTask.join() + rightTask.join();
}
return result;
}
public static void main(String[] args) throws
ExecutionException , InterruptedException {
TestForkJoin test = new TestForkJoin (1, 10000);
ForkJoinPool forkJoinPool = new ForkJoinPool();
Future result = forkJoinPool.submit(test);
System.out.println(result.get());
}
}
ForkJoin 使用 ForkJoinPool 来启动,它是一个特殊的线程池,线程数量取决于 CPU 核数。
ForkJoinPool 实现了工作窃取算法来提高 CPU 的利用率。每个线程都维护了一个双端队列,用来存储需要执行的任务。工作窃取算法允许空闲的线程从其它线程的双端队列中窃取一个任务来执行。窃取的任务必须是最晚的任务,避免和队列所属线程发生竞争。
6.并发容器
我们知道HashMap不是一个线程安全的容器,最简单的方式使HashMap变成线程安全就是使用Collections.synchronizedMap,它是对HashMap的一个包装。
public static Map m=Collections.synchronizedMap(new HashMap());
同理对于List,Set也提供了相似方法。但是这种方式只适合于并发量比较小的情况。
查看源码可以知道,synchronizedMap会将HashMap包装在里面,然后将HashMap的每个操作都加上synchronized。由于每个方法都是获取同一把锁,这就意味着,put和remove等操作是互斥的,大大减少了并发量。
通常我们会使用JUC包下的ConcurrentHashMap类。在 ConcurrentHashMap内部有一个Segment段,它将HashMap切分成若干个段(小的HashMap),然后让数据在每一段上Hash,这样多个线程在不同段上的Hash操作一定是线程安全的,所以只需要同步同一个段上的线程就可以了,这样实现了锁的分离,大大增加了并发量。
在使用ConcurrentHashMap.size()时会比较麻烦,因为它要统计每个段的数据和,在这个时候,要把每一个段都加上锁,然后再做数据统计。
7.锁优化
这里的锁优化,是指在阻塞式的情况下,如何让性能不要变得太差。
锁优化主要有一下几个方面:
- 减少锁持有时间。比如在使用synchronized时,保证线程安全前提下,尽量使用同步块,减少直接在方法上使用。
- 减小锁粒度。将大的对象(这个对象会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。比如ConcurrentHashMap就是使用了这一点。
- 锁分离。比如读写锁ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能。
- 锁粗化。通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。但是,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统资源,反而不利于性能的优化 。比如在一个方法中有很多的synchronized块,可以考虑把这些小块合并成大块。
- 锁消除。锁消除是在编译器级别的事情。在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作。比如Vector和StringBuffer这样的类,它们中的很多方法都是有锁的。当我们在一些不会有线程安全的情况下使用这些类的方法时,达到某些条件时,编译器会将锁消除来提高性能。在server模式下开启锁消除
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
。
JVM虚拟机对synchronized 的优化
自旋锁
互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
偏向锁和轻量级锁
锁拥有的四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。
轻量级锁相对于传统的重量级锁而言,它使用 CAS(比较并替换) 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。
以下图片是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。
当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。
如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。
偏向锁即锁会偏向于当前已经占有锁的线程 。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向后恢复到未锁定状态或者轻量级锁状态。
参考资料
高并发Java
JDK1.8-API