1、Java并发关键字
1.1 CountDownLatch
CountDownLatch基于线程计数器来实现并发访问控制,主要用于主线程等待其他子线程都执行完毕后执行相关操作。
以一个主任务等待两个子任务完成为例:
final CountDownLatch countDownLatch = new CountDownLatch(2); Thread thread1 = new Thread(){ public void run(){ try{ System.out.println("线程1正在执行"); //处理业务代码 Thread.sleep(3000); System.out.println("线程1执行结束"); countDownLatch.countDown(); } catch (InterruptedException e) { e.printStackTrace(); } } }; Thread thread2 = new Thread(){ public void run(){ try{ System.out.println("线程2正在执行"); //处理业务代码 Thread.sleep(2000); System.out.println("线程2执行结束"); countDownLatch.countDown(); } catch (InterruptedException e) { e.printStackTrace(); } } }; thread1.start(); thread2.start(); try { System.out.println("等待两个子线程执行完毕"); countDownLatch.await(); System.out.println("子线程执行完毕,开始执行主线程"); } catch (InterruptedException e) { e.printStackTrace(); }
以上代码先定义了一个大小为2的CountDownLatch,然后定义了两个子线程,子线程执行完业务代码后执行countDown方法减少一个信号量,表示该子线程执行结束。主线程调用await方法阻塞等待,在所有子线程都执行完成后,程序会主动唤醒主线程并开始执行主线程业务代码。
1.2 CyclicBarrier
CyclicBarrier实现让一组线程等待至某个状态之后再全部同时执行。在所有等待线程都被释放后,CyclicBarrier可以重用。在调用await方法后,线程就处于Barrier状态。await方法有两种实现:
- public int await():挂起当前线程直到所有线程都为Barrier状态再同时执行后续任务。
- public int await(long timeout, TimeUnit unit):设置一个超时时间
具体使用方法如下:
public static void main(String[] args) { int N = 4; CyclicBarrier barrier = new CyclicBarrier(N); for(int i = 0; i<N; i++){ new BusinessThread(barrier).start(); } } private static class BusinessThread extends Thread { private CyclicBarrier barrier; public BusinessThread(CyclicBarrier barrier) { this.barrier = barrier; } @Override public void run(){ try{ Thread.sleep(3000); System.out.println("线程完成准备工作,等待其他线程准备工作完成"); barrier.await(); } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } }
以上代码先定义了一个CyclicBarrier,然后循环启动了多个线程,线程开始执行第一阶段工作,等第一阶段工作处理完成,再调用await方法等待其他线程完成第一阶段工作;当所有线程都完成第一阶段工作后,开始执行下一项任务。
1.3 Semaphore
Semaphore是指信号量,用于控制同时访问某些资源的线程个数,具体做法为通过调用acquire()获取一个许可,如果没有许可,则等待;在许可使用完毕后通过release()释放该许可,以便其他线程使用。
例如办公室有两台打印机,有五个员工需要使用,只有在打印机使用完毕后才能让其他员工使用,具体代码如下:
public static void main(String[] args) { int workerNumber = 5;//员工数量 int printNumber = 2;//打印机数量 Semaphore semaphore = new Semaphore(printNumber); for (int i = 0; i < workerNumber; i++) { new Worker(i,semaphore).start(); } } private static class Worker extends Thread{ private int num; private Semaphore semaphore; public Worker(int num, Semaphore semaphore) { this.num = num; this.semaphore = semaphore; } @Override public void run(){ try { semaphore.acquire(); System.out.println("员工"+this.num+"占用一台打印机"); Thread.sleep(3000); System.out.println("员工"+this.num+"使用完成,释放一台打印机"); semaphore.release(); } catch (InterruptedException e) { e.printStackTrace(); } } }
Semaphore中有以下几个重要方法:
- public void acquire():以阻塞的方式获取一个许可,再有许可时返回许可,在没有许可时阻塞等待,直到获得许可。
- public void acquire(int permits):同时获取permits个许可。
- public void release():释放某个许可。
- public void release(int permits):释放permits个许可。
- public boolean tryAcquire():以非阻塞方式获取一个许可,在有可用许可时获取该许可并返回true,否则返回false。
1.4 CountDownLatch、CyclicBarrier和Semaphore的区别
- CountDownLatch和CyclicBarrier都用于实现多线程之间的相互等待。CountDownLatch主要用于主线程等待其他子线程任务均执行完成后再执行业务代码。CyclicBarrier主要用于一组线程互相等待所有线程都达到某个状态后,在同时执行接下来的业务代码。
- CountDownLatch是不可重用的,而CyclicBarrier是可重用的。
- Semaphore主要用于控制资源的并发访问,与Java中的锁功能类似。
1.5 volatile
volatile用于确保将变量的更新操作通知到其他线程。volatile具备两种特性:可见性和禁止指令重排。volatile主要适用于一个变量被多个线程共享,多个线程均可以针对这个变量执行赋值或读取操作。
在有多个线程对普通变量进行读写时,每个线程都首先会将数据从内存中复制到CPU缓存中,这样每个线程对同一变量做出不同处理后可能存在数据不一致的情况。如图:
当使用volatile修饰变量时,意味着任何对此变量的操作都会在内存中进行,不会产生副本,以保证共享变量的可见性,局部阻止了指令重排的发生。如图:
注:volatile不具备原子性,不能保证像i++这种操作的原子性。变量只有在状态真正独立于程序其他内容时才能使用volatile。
2、CAS
CAS(Compare and Swap)是指比较并交换。CAS算法包含三个参数CAS(V, E, N),V表示要更新的变量,E表示预期的值,N表示新值。当且仅当V值等于E值时,才会将V值设置为N值,如果V值和E值不同,则线程什么都不做。
CAS操作采用乐观锁的思想。在多线程同时使用CAS操作一个变量时,只有一个线程会成功完成操作,其余均会失败。失败的线程不会被挂起,会被告知失败,并且允许再次尝试。
2.1 CAS自旋等待
AtomicInteger的内部是基于CAS算法实现的,在某个线程进入方法中执行其中的指令时,不会被其他线程打断;而别的线程会自旋,一直等到该方法执行完成才由JVM从等待的队列中选择从另一个线程进入。JDK具体实现如下:
public class AtomicInteger extends Number implements java.io.Serializable { private volatile int value; public final int get() { return value; } public final int getAndUpdate(IntUnaryOperator updateFunction) { int prev, next; do { prev = get(); next = updateFunction.applyAsInt(prev); } while (!compareAndSet(prev, next)); return prev; } public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } }
2.2 ABA问题
ABA问题是指第1个线程从内存的V位置取出A,这时第2个线程也从内存中取出A,并将V位置的数据修改为B,接着又将V位置的数据修改为A,这时第1个线程在进行CAS操作时会发现内存中仍然是A,然后第1个线程操作成功。这个过程中V位置的数据已经发生变化,只是第1个线程没有感知到,在某些场景下可能会出现数据不一致问题。
部分乐观锁可以通过版本号来解决ABA问题在执行数据修改操作时会带上一个版本号,在预期的版本号和数据的版本号一致时就可以执行修改操作,并且对版本号执行加1操作,否则执行失败。每次操作版本号都会随之增加,所以不会出现ABA问题。
3、AQS
AQS是一个抽象的队列同步器,通过维护一个共享资源状态(volatile int state)和一个先进先出(FIFO)的线程等待队列来实现一个多线程访问共享资源的同步框架。
3.1 AQS的原理
AQS为每个共享资源都设置一个共享资源锁,线程在需要访问共享资源时首先需要获取共享资源锁,如果获取到了共享资源锁,便可以在当前线程中使用该共享资源,如果获取不到,则将该线程放入线程等待队列。如图:
3.2 state
state表示当前的同步状态,使用volatile修饰,保证当前变量state的可见性。
state的访问方式有三种:getState()、setState()和compareAndSetState()。代码如下:
/** * The synchronization state. */ private volatile int state; /** * 返回共享资源状态 */ protected final int getState() { return state; } /** * 设置共享资源状态 */ protected final void setState(int newState) { state = newState; } /** * 以原子方式将同步状态设置为给定的更新值(如果当前状态值等于预期值) */ protected final boolean compareAndSetState(int expect, int update) { // See below for intrinsics setup to support this return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }
3.3 AQS共享资源的方式:独占式和共享式
AQS定义了两种资源共享方式:独占式(Exclusive)和共享式(Share)。
- 独占式:只有一个线程能执行,如ReentrantLock。
- 共享式:多个线程可同时执行,如Semaphore、CountDownLatch。
不同的自定义同步器争用共享资源的方式也不同,自定义同步器在实现时只需实现共享资源state的获取与释放方式即可。主要方法如表:
一般来说,自定义同步器实现类只需要实现tryAcquire、tryRelease或tryAcquireShared、tryReleaseShared中的一组即可。AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock在读时采用共享方式,在写时采用独占方式。
4、ConcurrentHashMap并发
ConcurrentHashMap采用分段锁的思想支持并发操作,是线程安全的。
4.1 ConcurrentHashMap的实现
ConcurrentHashMap内部细分为多个HashMap,叫作数据段(Segment)。默认情况下,一个ConcurrentHashMap分为16个数据段,每个数据段的数据都单独进行加锁操作。Segment的个数为锁的并发度。
ConcurrentHashMap是由Segment数组和HashEntry数组组成的。Segment是数组和链表组成的结构,在每个Segment都包含一个HashEntry数组,每个HashEntry都是一个链表结构的数据,在对HashEntry数组的数据进行修改时,必须首先获得对应的Segment锁。
如果需要在ConcurrentHashMap中添加一个新数据,并不是对整个HashMap加锁,会先根据HashCode查询该数据应该会被存放在哪一段,然后对该段进行加锁并完成put操作。在多线程环境下,如果有多个线程同时进行put操作,只要加入的数据被存放在不同的段中,就可以做到并行的线程操作。
4.2 减小锁粒度
减小锁粒度:通过缩小锁定对象的范围来减少锁冲突的可能性。ConcurrentHashMap并发下的安全机制就是基于该方法实现的。