并发编程
强软弱虚与ThreadLocal
1、强引用
即普通的引用,常见的都是
public class C00_NormalReference {
public static void main(String[] args) {
User user = new User();
user=null;
System.gc();
ThreadUtil.sleepSeconds(3);
}
}
2、软引用
通过 SoftReference 构建软引用,
public class C01_SoftReference {
// 做实验时 设置heap最大20M,-Xmx20M
public static void main(String[] args) {
SoftReference<byte[]> s = new SoftReference<>(new byte[1024*1024*10]);
//获取软引用的数据
System.out.println(s.get());
//gc
System.gc();
ThreadUtil.sleepSeconds(2);
//再次获取软引用的数据
System.out.println(s.get());
//在堆中再次分配一个byte[] 导致heap分配不下,此时会触发垃圾回收,如果空间不够会把软引用的内存回收
byte[] bytes = new byte[1024*1024*12];
System.out.println(s.get());
}
// 软引用一般主要应用到缓存上
}
软引用图解如下:
一般用于缓存。
3、弱引用
通过 WeakReference 构建弱引用
public static void main(String[] args) {
//创建弱引用
WeakReference<User> w = new WeakReference<>(new User());
//获取弱引用的对象
System.out.println(w.get());
System.gc();
// 被弱引用所引用的对象,如果在没有被其他强引用多引用的情况下会被回收。
System.out.println(w.get());
}
正常情况下能通过 get 获取弱引用所引用的对象,但是当 gc 后被弱引用所引用的对象如果再没有其他强引用的情况下就被回收了。
弱引用图解如下:
那么它的应用场景在哪呢?
典型的场景:ThreadLocal
4、虚引用
通过 PhantomReference 构建虚引用
public static void main(String[] args) {
ReferenceQueue<User> QUEUE = new ReferenceQueue<>();
PhantomReference<User> p = new PhantomReference<>(new User(), QUEUE);
System.out.println(p.get());
//p=null;
new Thread(()->{
while (true) {
byte[] b = new byte[1024*1024*4];
b=null;
ThreadUtil.sleepSeconds(1);
System.out.println(p.get());
}
}).start();
new Thread(()->{
while (true) {
Reference<? extends User> reference = QUEUE.poll();
if (reference!=null) {
System.out.println("---虚引用对象被jvm回收了--"+reference);
break;
}
}
}).start();
ThreadUtil.sleepSeconds(5);
}
这下好了通过虚引用所引用的区域连 get 都获取不到,那这个的应用场景
是什么呢?:管理堆外内存
如下图所示
5、ThreadLocal
ThreadLocal为解决多线程程序的并发问题提供了一种新的思路,我们一般称其为线程本地变量而非直译的本地线程,当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。从线程的角度看,目标变量就像是线程的本地变量,这也是类名中“Local”所要表达的意思。
关于它的使用其实很简单就是三个核心API, set , get , remove 。
下面有个例子
private static final ThreadLocal<User> tl = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
// 认识 threadlocal
Thread t1 = new Thread(() -> {
ThreadUtil.sleepSeconds(1);
try {
User user = new User();
tl.set(user);
System.out.println("t1 set user="+tl.get());
} finally {
ThreadUtil.sleepSeconds(2);
tl.remove();
}
});
Thread t2 = new Thread(() -> {
ThreadUtil.sleepSeconds(2);
User user = tl.get();
System.out.println("t2 get user="+user);
});
//启动两个线程
t1.start();
t2.start();
t1.join();
t2.join();
}
//查看 threadlocal 的原理
static ThreadLocal<User> t = new ThreadLocal<User>();
public static void threadlocal() {
t.set(new User());
t.get();
t.remove();
}
能说明 ThreadLocal 存储变量确实跟线程有关系,在当前线程中存的能在当前线程中取,其他线程中获取不到。
那 ThreadLocal 是如何做到只跟当前线程有关系的呢?
我们依次来分析 ThreadLocal 的源码:
首先来看: set
public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
// 拿到当前线程对象的 ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null)
//向当前线程的ThreadLocalMap中存入数据,k=this就是当前 调用set方法的ThreadLocal,v=要存储的数据
map.set(this, value);
else
createMap(t, value);
}
每个线程 Thread 都有自己的 ThreadLocalMap
public
class Thread implements Runnable {
/* Make sure registerNatives is the first thing <clinit> does. */
private static native void registerNatives();
static {
registerNatives();
}
private volatile String name;
private int priority;
private Thread threadQ;
private long eetop;
然后我们来看向当前线程的 ThreadLocalMap 中存数据是怎么存的
private void set(ThreadLocal<?> key, Object value) {
// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 真正存储到ThreadLocalMap中,看Entry的定义
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
由此可见 Entry 是一个指向 ThreadLocal 的弱引用。
整体结构如下图
Lock&Condition
并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。
synchronized , wait , notify/notifyAll ,这套组合就可以用来解决互斥和同步的问题,这是JDK在语言层面提供的一种实现,另外JDK在SDK层面还提供了另外一种实现(特别注意这是java SDK层面),那就是 Lock 和 Condition ,那这算不算重复造轮子呢?
答案不是,这两套实现之间是有巨大区别的,区别是什么呢?
1、 synchronized 在已经获取了锁A的情况下去获取锁B,如果锁B获取不到,它不会释放锁A
2、 synchronized 获取锁B时获取不到时会进入阻塞状态,啥也干不了,也不能被中断/打断,也就无法去释放已获得的资源
这也是我们发生死锁时的两个条件:1,保持和等待;2,不可被剥夺/抢占
所以针对 synchronized 的这些弊端,我们的并发大师 Doug Lea 提供了JUC 下的诸多实现,而他提供的新的锁具备以下这几个特征:
1、能够被中断:这个解决的就是 synchronized 获取不到锁时进入阻塞,也不能被中断的特性,如果获取不到锁进入阻塞后还能收到中断信号被唤醒,那么它就有机会去释放曾经锁定过的资源,避免死锁。
2、支持超时获取:如果线程在一段时间之内没有获取到锁,不是进入一直阻塞的状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁,避免死锁
3、支持非阻塞的获取锁:如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁,避免死锁体现在 API 上,就是 Lock 接口的几个方法,如下:
// 支持中断的API
void lockInterruptibly() throws InterruptedException;
// 支持超时获取的API
boolean tryLock(long time, TimeUnit unit) throws
InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();
//普通获取锁的API
void lock();
//释放锁
void unlock();
//条件变量
Condition newCondition();
下图是 Lock 接口的实现体系:
其中比较熟知的譬如: ReenTrantLock 可重入锁,所谓可重入锁,指的是线程可以重复获取同一把锁。有关这个几个API的使用参考简易代码如下:
public class C00_HowToUseLock {
//ReentrantLock:支持公平非公平模式
private static Lock lock = new ReentrantLock();
//累加结果
private static int count;
public static void main(String[] args) throws Exception {
//启动numOfThreads个线程
int numOfThreads = 1000;
Thread[] threads = new Thread[numOfThreads];
for (int i=0;i<numOfThreads;i++) {
threads[i] = new Thread(() -> {
//每个线程执行累加操作
addCount();
});
threads[i].start();
}
//等待执行结束
for (Thread t:threads) {
t.join();
}
System.out.println("count="+count);
}
private static void addCount() {
/*for (int i=0;i<1000;i++) {
count++;
}*/
// 1、lock/unlock 经典:try {} finally{}
try {
lock.lock();
for (int i=0;i<1000;i++) {
count++;
}
}finally {
lock.unlock();
}
// 2、支持超时获取锁且支持中断 tryLock(5, TimeUnit.SECONDS)
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
for (int i=0;i<1000;i++) {
count++;
}
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
// 3、非阻塞获取锁 tryLock()
if (lock.tryLock()) {
try {
for (int i=0;i<1000;i++) {
count++;
}
} finally {
lock.unlock();
}
}
//4, 获取锁时支持被中断
try {
try {
lock.lockInterruptibly();
for (int i=0;i<1000;i++) {
count++;
}
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在使用 synchronized 时,我们要配合使用 wait , notify/notifyAll 来进行线程的等待和通知,而在使用 Lock 时我们需要使用 Condition 中提供的await() 、 signal() 、 signalAll() ,并且一个 Lock 是可以支持多个Condition 的;它们的语义和 wait() 、 notify() 、 notifyAll() 是相同的。但要注意的是这两套不能错乱使用,Lock&Condition 只能使用前面的await()、signal()、signalAll(),而后面的 wait()、notify()、notifyAll() 只有在synchronized 里才能使用。
下面我们以一个案例来进行说明:要求快速实现一个有界阻塞队列,队列
为空时元素不能出队列,队列已满时元素不能入队列
实现如下
public class C01_Condition {
public static void main(String[] args) {
MyBlockingQueue<String> queue = new MyBlockingQueue<>(10);
new Thread(()->{
for(int i=0;i<100;i++) {
String e = "ele"+i;
queue.offer(e);
System.out.println("offer "+e);
}
}).start();
ThreadUtil.sleepSeconds(1);
new Thread(()->{
while (true) {
String s = queue.poll();
System.out.println("poll "+s);
}
}).start();
}
}
class MyBlockingQueue<E> {
final Queue<E> queue;
private int queuSize;
final Lock lock = new ReentrantLock();
//队列已满的条件变量
final Condition notFull = lock.newCondition();
//队列为空的条件变量
final Condition notEmpty = lock.newCondition();
//构建大小为:numElements 的有界队列
public MyBlockingQueue(int numElements){
queue = new ArrayDeque<E>(numElements);
queuSize = numElements;
}
public boolean offer2(E e) {
return queue.offer(e);
}
public E poll2() {
return queue.poll();
}
//元素入队列
public boolean offer(E e) {
lock.lock();
boolean offer;
try {
//如果队列已满,等待
while (queue.size() == queuSize) {
try {
notFull.await();
} catch (InterruptedException i) {
}
}
//执行入队列操作
offer = queue.offer(e);
// 入队列后通知可出队列
notEmpty.signal();
} finally {
lock.unlock();
}
return offer;
}
//元素出队列
public E poll() {
E e = null;
lock.lock();
try {
//队列为空,等待
while (queue.isEmpty()) {
try {
notEmpty.await();
} catch (InterruptedException i) {
i.printStackTrace();
}
}
//执行元素出队列 操作
e = queue.poll();
//出队列后通知可入队列、
notFull.signal();
} finally {
lock.unlock();
}
return e;
}
}
Semaphore
1、什么是信号量
Semaphore,现在普遍翻译为“信号量”,由大名鼎鼎的计算机科学家迪杰斯特拉(Dijkstra)于 1965 年提出,目前几乎所有支持并发编程的语言都支持信号量机制,所以学好信号量还是很有必要的。首先我们要来学习操作系统信号量机制,还得从操作系统中任务和资源的共享情况说起:
- 在一段时间内之允许一个任务访问共享资源,多个任务间要互斥
- 在一段时间内允许多个任务同时访问同一共享资源的的多个实例
这种情况我们可以用信号量解决,而信号量可以分为以下三种类型:
1、互斥信号量:任务之间通过互斥信号量访问临界资源,这其实就是锁机
制
2、计数信号量:任务之间竞争性的访问共享资源
3、二值信号量:任务之间的同步机制
所以说:信号量是操作系统提供的管理资源共享的有效手段。
2、信号量模型
下面来说一下信号量模型,这个模型其实很简单,可以简单概括为:
- 一个计数器:整数值,实现对资源计数(s.count),
- 一个等待队列:s.queue,资源不够时调用进程/线程进入阻塞队列等 待,
- 三个操作方法:初始化,P操作,V操作,
初始化:主要是将信号量计数器count指定为一个非负整数值,表示可用共享资源的实例总数,允许中count可能做减法(资源被分配)而变成负数,那么它的绝对值表示当前等待访问共享资源的进程/线程数。
P操作:又叫 wait(s) ,代表对资源的分配,申请对应的资源,伪代码如下
--s.count ;//表示申请一个资源
if (s.count < 0 ) {//表示没有空闲资源
//调用进程/线程进入阻塞队列 s.queue
//阻塞等待
}
V操作:又叫 signal(s) ,代表对资源的释放,伪代码如下
++s.count;//表示释放一个资源
if (s.count <=0) { //表示有进程/线程处于阻塞等待状态
//从队列s.queue中取出一个进程/线程
//进程/线程进入就绪执行
}
整体如下图所示:
下面我们来说一下三种信号量的操作原理:
1、互斥信号量
这其实就是一种互斥锁,所以用信号量可以实现锁
2、计数信号量
一般用于控制多个线程访问一个临界区。
3、二值信号量
一般用于线程同步,某线程等待另一线程释放信号后才能执行某操作。
3、Java SDK 实现
在 Java SDK 里面,信号量模型是由 java.util.concurrent.Semaphore实现的。(Semaphore跟 ReentrantLock 一样也分公平和不公平,默认不公平)
Semaphore 这个类对应的几个操作分别是: acquire() , release() ,对于信号量的使用一般有以下几种,比如:
1、互斥信号量,充当互斥锁
public class C02_Semaphore {
private static int count;
//初始化信号量
private static final Semaphore s = new Semaphore(1);
//用信号量保证线程安全
static void addOne() {
try {
try {
s.acquire();
count++;
} finally {
s.release();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
static void subOne() {
try {
try {
s.acquire();
count--;
} finally {
s.release();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws Exception{
int numsOfThread = 100000;
CountDownLatch c = new CountDownLatch(numsOfThread*2);
for (int i=0;i<numsOfThread;i++) {
new Thread(()->{
addOne();
c.countDown();
}).start();
}
for (int i=0;i<numsOfThread;i++) {
new Thread(()->{
subOne();
c.countDown();
}).start();
}
c.await();
System.out.println("count="+count);
}
}
2、计数信号量:实现一个限流器,比较常见的需求就是我们工作中遇到的各种池化资源,例如连接池、对象池、线程池等等。其中,你可能最熟悉数据库连接池,在同一时刻,一定是允许多个线程同时使用连接池的,当然,每个连接在被释放前,是不允许其他线程使用的。
比如现在有一个对象池的需求。所谓对象池呢,指的是一次性创建出 N 个对象,之后所有的线程重复利用这 N 个对象,当然对象在被释放前,也是不允许其他线程使用的。
实现如下
public class C03_SemObjectPool {
public static void main(String[] args) throws Exception {
//创建对象池
ObjectPool<Bbd,String> pool = new ObjectPool(10,Bbd.class);
int numsOfThread = 1000;
CountDownLatch c = new CountDownLatch(numsOfThread);
for (int i=0;i<numsOfThread;i++) {
int finalI = i;
new Thread(()->{
//向对象池提交任务
String s = pool.execute((obj) -> {
System.out.println("从对象池中获取的对象"+obj);
ThreadUtil.sleepSeconds(1);
return obj.hurt("唐三"+ finalI);
});
System.out.println(s);
c.countDown();
}).start();
}
c.await();
}
}
class ObjectPool<T,R> {
//对象池
private final List<T> pool;
// 用信号量实现限流器
private final Semaphore SEMAPHORE;
ObjectPool(int poolSize,Class<?> clas) {
pool = new Vector<>(poolSize); //能否换成ArrayList
//按照给定的大小初始化对象池
for (int i=0;i<poolSize;i++) {
try {
pool.add((T) clas.newInstance());
} catch (InstantiationException e) {
} catch (IllegalAccessException e) {
}
}
//创建计数信号量
SEMAPHORE = new Semaphore(poolSize);
}
/**
* 向对象池中提交任务,由对象池中的对象来执行
* @param function
* @return
*/
R execute(Function<T,R> function) {
T obj = null;
try {
//获得信号量
SEMAPHORE.acquire();
try {
//从对象池中获取对象
obj = pool.remove(0);
// 执行对象操作
return function.apply(obj);
} finally {
//将对象归还池
pool.add(obj);
//释放信号量
SEMAPHORE.release();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
}
class Bbd {
public String hurt(String name) {
String r = "xxx " +name;
return r;
}
}
思考:了解了互斥信号量和计数信号量的使用后,对于二值信号量的使用呢?
其实很简单,初始值为0即可。
ReadWriteLock&StampedLock
1、什么是读写锁
JUC包中提供了一个接口java.util.concurrent.locks.ReadWriteLock ,读写锁,java已经在语言
层面提供了 synchronized ,SDK层面有 Lock , Semaphore ,为什么还要提供ReadWriteLock 呢?
原因很简单**:分场景优化性能,提升易用性**
比如一种非常普遍的并发场景:读多写少场景。实际工作中,为了优化性能,我们经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)。针对读多写少这种并发场景,JavaSDK 并发包提供了读写锁—— ReadWriteLock ,非常容易使用,并且性能很好。
那什么是读写锁?
读写锁,并不是 Java 语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守以下三条基本原则:
- 允许多个线程同时读共享变量;但读的时候禁止写
- 只允许一个线程写共享变量;
- 如果一个写线程正在执行写操作,此时禁止读线程读共享变量(由第二 条可知自然也禁止其他线程写)。
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。
我们了看一下 ReadWriteLock 接口中的定义以及实现体系
public interface ReadWriteLock {
Lock readLock();//返回读锁
Lock writeLock();//返回写锁
}
由图中可知,典型的实现就是 ReentrantReadWriteLock ,是一个支持可重入的读写锁。
2、读写锁使用范例
需求:下面我们基于 ReadWriteLock 快速实现一个缓存工具类
实现:声明了一个 Cache<K, V> 类,其中类型参数 K 代表缓存里 key 的类型,V 代表缓存里 value 的类型。缓存的数据保存在 Cache 类内部的HashMap 里面,HashMap 不是线程安全的,这里我们使用读写锁ReadWriteLock 来保证其线程安全。ReadWriteLock 是一个接口,它的实现类是ReentrantReadWriteLock,通过名字你应该就能判断出来,它是支持可重入的。
class Cache<K,V> {
//缓存数据的存储
private final Map<K,V> cache = new HashMap<>();
// 构建 ReadWriteLock
private final ReadWriteLock rwl = new ReentrantReadWriteLock();
//读锁
private final Lock read = rwl.readLock();
//写锁
private final Lock write = rwl.writeLock();
//向缓存中
public V put(K k,V v) {
write.lock();
//System.out.println(Thread.currentThread().getName()+" get write lock");
try {
return cache.put(k, v);
} finally {
//System.out.println(Thread.currentThread().getName()+" release write lock");
write.unlock();
}
}
// 从缓存中读数据
public V get(K k) {
read.lock();
//System.out.println(Thread.currentThread().getName()+" get read lock");
try {
return cache.get(k);
} finally {
//System.out.println(Thread.currentThread().getName()+" release read lock");
read.unlock();
}
}
另外这块要注意的是读写锁的嵌套问题,我们可以称读写锁的升级比如在 get 缓存数据时,如果缓存中不存在,我们选择去数据库查询数据,然后再将数据存入缓存,伪代码如下
// 读写锁嵌套:读锁升级写锁 读锁未释放无法获取写锁
public V get2(K k) {
V v = null;
read.lock();
try {
v = cache.get(k);
if (v == null) {
try {
//从数据库查询并写入缓存
write.lock();
v = queryFromDb(k);
v = cache.put(k,v);
} finally {
write.unlock();
}
}
} finally {
read.unlock();
}
return v;
}
这样看上去好像是没有问题的,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫读锁的升级。可惜 ReadWriteLock 并不支持这种升级。在上面的代码示例中,读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。读锁的升级是不允
许的,这个一定要注意。
但是虽然读锁的升级是不允许的,但是写锁的降级却是允许的。以下代码来源自ReentrantReadWriteLock 的官方示例,略做了改动
class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// Must release read lock before acquiring write lock
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// Recheck state because another thread might have
// acquired write lock and changed state before we did.
if (!cacheValid) {
// data = ... ; // 查询数据
cacheValid = true;
}
// Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // Unlock write, still hold read
}
}
try {
// use(data);//使用数据
} finally {
rwl.readLock().unlock();
}
}
}
总之:
读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock()方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意,那就是只有写锁支持条件变量Condition,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。
3、StampedLock
我们讲读写锁 ReadWriteLock 允许多个线程同时读共享变量,适用于读多写少的场景”。那在读多写少的场景中,还有没有更快的技术方案呢?
Java 在 1.8 这个版本里,提供了一种叫 StampedLock 的锁,它的性能就比读写锁还要好,我们来看 StampedLock 和 ReadWriteLock 有哪些区别?
ReadWriteLock 支持两种模式:一种是读锁,一种是写锁。而StampedLock 支持三种模式,分别是:写锁、悲观读锁和乐观读。
其中,写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。
不同的是:StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp。相关的示例代码如下。
public class C06_StampedLock {
private final StampedLock sl = new StampedLock();
//写锁
public void write(Object v) {
//获取写锁
long stamp = sl.writeLock();
try {
//省略相关业务代码
} finally {
sl.unlockWrite(stamp);
}
}
// 悲观读锁
public void pessimisticRead() {
long stamp = sl.readLock();
try {
//省略相关业务代码
} finally {
sl.unlockRead(stamp);
}
}
StampedLock 的性能之所以比 ReadWriteLock 还要好,其关键是StampedLock 支持乐观读的方式。ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而StampedLock 提供的乐观读,在允许多个线程同时读的情况下还能允许一个线程获取写锁,也就是说
不是所有的写操作都被阻塞。
注意,我们用的是“乐观读”这个词,而不是“乐观读锁”,是要提醒你,乐观读这个操作是无锁的,所以相比较 ReadWriteLock 的读锁,乐观读的性能更好一些。
下面有官方提供的案例代码
class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
void move(double deltaX, double deltaY) { // an exclusively locked method
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
// 乐观读操作
double distanceFromOrigin() { // A read-only method
//乐观读
long stamp = sl.tryOptimisticRead();
// 读到局部变量中
double currentX = x, currentY = y;
// 判断执行读操作期间,是否存在写操作,如果存在,则sl.validate返回false
if (!sl.validate(stamp)) {
// 需要重新读取数据,升级为悲观读锁
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
// 悲观读锁升级为写锁
void moveIfAtOrigin(double newX, double newY) { // upgrade
// Could instead start with optimistic, not read mode
long stamp = sl.readLock();
try {
while (x == 0.0 && y == 0.0) {
long ws = sl.tryConvertToWriteLock(stamp);
if (ws != 0L) {
stamp = ws;
x = newX;
y = newY;
break;
}
else {
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
sl.unlock(stamp);
}
}
在 distanceFromOrigin() 这个方法中,首先通过调用tryOptimisticRead() 获取了一个 stamp ,这里的 tryOptimisticRead()就是我们前面提到的乐观读。之后将共享变量 x 和 y 读入方法的局部变量中,
不过需要注意的是,由于 tryOptimisticRead() 是无锁的,所以共享变量 x和 y 读入方法局部变量时,x 和 y 有可能被其他线程修改了。因此最后读完之后,还需要再次验证一下是否存在写操作,这个验证操作是通过调用validate(stamp) 来实现的。
另外从官方给的案例代码中能发现, StampedLock 提供的悲观读锁是能尝试升级转换成写锁的,这点也是和 ReadWriteLock 不一样的地方。
4、StampedLock注意事项
进一步理解乐观读:
如果你曾经用过数据库的乐观锁,可能会发现 StampedLock 的乐观读和数据库的乐观锁有异曲同工之妙。的确是这样的,数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用version 字段做验证。这个version 字段就类似于 StampedLock 里面的 stamp。
StampedLock 使用注意事项:
对于读多写少的场景 StampedLock 性能很好,简单的应用场景基本上可以替代 ReadWriteLock,但是 StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。
1、StampedLock 在命名上并没有增加 Reentrant,想必你已经猜测到StampedLock 应该是不可重入的。事实上,的确是这样的,StampedLock 不支持重入。这个是在使用中必须要特别注意的。
2、StampedLock 的悲观读锁、写锁都不支持条件变量,这个也需要注意。
3、如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。
例如下面的代码中,线程 T1 获取写锁之后将自己阻塞,线程 T2 尝试获取悲观读锁,也会阻塞;如果此时调用线程 T2 的 interrupt() 方法来中断线程 T2的话,你会发现线程 T2 所在 CPU 会飙升到 100%。
public class C07_StampedAttention {
final static StampedLock lock = new StampedLock();
public static void main(String[] args) throws Exception{
Thread T1 = new Thread(()->{
// 获取写锁
lock.writeLock();
// 永远阻塞在此处,不释放写锁
LockSupport.park();
});
T1.start();
// 保证T1获取写锁
Thread.sleep(100);
Thread T2 = new Thread(()->
//阻塞在悲观读锁
lock.readLock()
);
T2.start();
// 保证T2阻塞在读锁
Thread.sleep(100);
//中断线程T2
//会导致线程T2所在CPU飙升
T2.interrupt();
T2.join();
}
}
4、使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁writeLockInterruptibly()。
CountDownLatch&CyclicBarrier
CyclicBarrier和CountDownLatch这两个工具都是在java.util.concurrent包下,并且平时很多场景都会使用到,
CountDownLatch是一个同步工具类(也叫线程计数器),用来协调多个线程之间的同步,CountDownLatch的两种经典用法如下:
1、一个线程在等待另外一些线程完成各自工作之后,再继续执行
// 一等多
static void m1() {
Random r = new Random();
int numsOfThread = 10;
CountDownLatch latch = new CountDownLatch(numsOfThread);
for (int i=0;i<numsOfThread;i++) {
new Thread(()->{
ThreadUtil.sleepSeconds(r.nextInt(numsOfThread));
System.out.println(Thread.currentThread().getName()+" 到了");
latch.countDown();
}, "项目组同事"+i).start();
}
// 等同事都到了再开饭
try {
latch.await();
//latch.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("干饭");
}
2、多个线程等待某一个线程完成工作后开始完成各自的工作
//多等一
static void m2() {
Random r = new Random();
int numsOfThread = 10;
CountDownLatch latch = new CountDownLatch(1);
for (int i=0;i<numsOfThread;i++) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+" 已准备就绪");
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 起跑");
ThreadUtil.sleepSeconds(r.nextInt(numsOfThread)+1);
System.out.println(Thread.currentThread().getName()+" 跑到了终点");
}, "运动员"+i).start();
}
ThreadUtil.sleepSeconds(1);
System.out.println("2s后发令员准备发号");
ThreadUtil.sleepSeconds(2);
System.out.println("枪响了");
latch.countDown();
}
CountDownLatch的不足:
1、CountDownLatch是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当CountDownLatch使用完毕后,它不能再次被使用
CyclicBarrier
CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续工作,CyclicBarrier是一种同步机制允许一组线程相互等待,等到所有线程都到达一个屏障点才退出await方法。
使用方式如下:
static void m1() {
Random r = new Random();
int numsOfThread = 10;
// 带Runnable的构造,当所有线程都到达屏障点后优先执行该Runnable
CyclicBarrier cyclicBarrier = new CyclicBarrier(numsOfThread,()->{
System.out.println("所有运动员已就位,枪响了");
});
for (int i=0;i<numsOfThread;i++) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+" 已准备就绪");
try {
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 起跑");
ThreadUtil.sleepSeconds(r.nextInt(numsOfThread)+1);
System.out.println(Thread.currentThread().getName()+" 跑到了终点");
}, "男子运动员"+i).start();
}
ThreadUtil.sleepSeconds(10);
System.out.println("============女子组开始比赛===========");
//重置cyclicBarrier 因为cyclicBarrier可以重复利用
//cyclicBarrier.reset();//会自动重置
for (int i=0;i<numsOfThread;i++) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+" 已准备就绪");
try {
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 起跑");
ThreadUtil.sleepSeconds(r.nextInt(numsOfThread)+1);
System.out.println(Thread.currentThread().getName()+" 跑到了终点");
}, "女子运动员"+i).start();
}
}
CyclicBarrier和CountDownLatch的比较:
1、 CountDownLatch的计数器只能使用一次。而CyclicBarrier的计数器可以使用reset()
2、因为可以reset()。所以CyclicBarrier能处理更为复杂的业务场景,比如如果计算发生错误,可以重置计数器,并让线程们重新执行一次
3、CyclicBarrier还提供其他有用的方法,比如getNumberWaiting方法可以获得CyclicBarrier阻塞的线程数量。isBroken方法用来知道阻塞的线程是否被中断
4、某线程中断CyclicBarrier会抛出异常,避免了所有线程无限等待
总之:CountDownLatch是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而CyclicBarrier更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。
AQS框架
1、前言
如果要想真正的理解 JUC 下的并发工具的实现原理,我们必须要来学习AQS ,因为它是 JUC 下很多类的基石。
在讲解AQS之前,如果老板让你自己写一个SDK层面的锁,给其他同事去使用,你会如何写呢?
1、搞一个状态标记,用来表示持有或未持有锁,但得是 volatile 类型的保证线程可见性。
2、编写一个 lock , unlock 函数用于抢锁和释放锁,就是对状态标记的修改操作
3、 lock 函数要保证并发下只能有一个线程能抢到锁,其他线程要等待获取锁(阻塞式),可以采用CAS+自旋的方式实现
初步实现如下:
public class MyLock {
// 定义一个状态变量status:为1表示锁被持有,为0表示锁未被持有
private volatile int status;
private static final Unsafe unsafe = reflectGetUnsafe();
private static final long valueOffset;
private static final Queue<Thread> QUEUE = new LinkedBlockingQueue<>();
static {
try {
valueOffset = unsafe.objectFieldOffset
(MyLock.class.getDeclaredField("status"));
} catch (Exception ex) { throw new Error(ex); }
}
private static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 阻塞式获取锁
* @return
*/
public boolean lock() {
while (!compareAndSet(0,1)) {
}
return true;
}
// cas 设置 status
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
/**
* 释放锁
*/
public void unlock() {
status = 0;
}
}
问题:获取不到锁自旋时,是空转,浪费CPU
1、使用 yield 让出CPU执行权,等待调度
/**
* 阻塞式获取锁
* @return
*/
public boolean lock() {
while (!compareAndSet(0,1)) {
Thread.yield();//yield+自旋,尽可能的防止CPU空转,让出CPU资源
LockSupport.park();//线程休眠
}
return true;
}
或者可以采用线程休眠的方式,但是休眠时间不太好确定,太长太短都不好。
2、采用等待唤醒机制,但是这里由于没有使用 synchronized 关键字,所以也无法使用 wait/notify ,但是我们可以使用 park/unpark ,获取不到锁的线程 park 并且去队列排队,释放锁时从队列拿出一个线程 unpark
/**
* 阻塞式获取锁
* @return
*/
public boolean lock() {
while (!compareAndSet(0,1)) {
QUEUE.offer(Thread.currentThread());
LockSupport.park();//线程休眠
}
return true;
}
/**
* 释放锁
*/
public void unlock() {
status = 0;
LockSupport.unpark(QUEUE.poll());
}
2、AQS概述
AQS(AbstractQueuedSynchronizer):抽象队列同步器,定义了一套多线程访问共享资源的同步器框架,提供了SDK层面的锁机制,JUC中的很多类譬如:ReentrantLock/Semaphore/CountDownLatch…等都是基于它。
通过查阅作者的对于该类的文档注释可以得到如下核心信息:
1、AQS用一个 volatile int state; 属性表示锁状态,1表示锁被持有,0表示未被持有,具体的维护由子类去维护,但是提供了修改该属性的三个方法: getState() , setState(int newState) ,compareAndSetState(int expect, int update) ,其中CAS方法是核心。
2、框架内部维护了一个FIFO的等待队列,是用双向链表实现的,我们称之为CLH队列,
3、框架也内部也实现了条件变量 Condition ,用它来实现等待唤醒机制,并且支持多个条件变量
4、AQS支持两种资源共享的模式:独占模式(Exclusive)和共享模式(Share),所谓独占模式就是任意时刻只允许一个线程访问共享资源,譬如ReentrantLock;而共享模式指的就是允许多个线程同时访问共享资源,譬如Semaphore/CountDownLatch
5、使用者只需继承 AbstractQueuedSynchronizer 并重写指定的方法,在方法内完成对共享资源 state 的获取和释放,至于具体线程等待队列的维护,AQS已经在顶层实现好了,在那些 final 的模板方法里。
* <p>To use this class as the basis of a synchronizer,
redefine the
* following methods, as applicable, by inspecting and/or
modifying
* the synchronization state using {@link #getState},
{@link
* #setState} and/or {@link #compareAndSetState}:
*
* <ul>
* <li> {@link #tryAcquire}
* <li> {@link #tryRelease}
* <li> {@link #tryAcquireShared}
* <li> {@link #tryReleaseShared}
* <li> {@link #isHeldExclusively}
* </ul>
*
* Each of these methods by default throws {@link
* UnsupportedOperationException}. Implementations of
these methods
* must be internally thread-safe, and should in general
be short and
* not block. Defining these methods is the <em>only</em>
supported
* means of using this class. All other methods are
declared
* {@code final} because they cannot be independently
varied.
6、AQS底层使用了模板方法模式,给我们提供了许多模板方法,我们直接使用即可。
3、基本使用
此时老板给你加了需求,要求你实现一个基于AQS的锁,那该怎么办呢?在 AbstractQueuedSynchronizer 的类注释中给出了使用它的基本方法,我们按照它的写法尝试即可
public class MyLock2 implements Lock {
//同步器
private Sync syn ;
MyLock2 () {
syn = new NoFairSync();
}
MyLock2 (boolean fair) {
syn = fair ? new FairSync():new NoFairSync();
}
@Override
public void lock() {
//调用模板方法
syn.acquire(1);
}
@Override
public void unlock() {
//调用模板方法
syn.release(0);
}
// 实现一个独占同步器
class Syn extends AbstractQueuedSynchronizer{
@Override
protected boolean tryAcquire(int arg) {
if (compareAndSetState(0,arg)) {
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
setState(arg);
return true;
}
}
}
4、原理解析
自己实现的锁在使用过程中发现一个问题,就是有时候有的线程特别容易抢到锁,而有的线程老是抢不到锁,虽说线程们抢锁确实看命,但能不能加入一种设计,让各个线程机会均等些,起码不要出现某几个线程总是特倒霉抢不到锁的情况吧!
这其实就是涉及到锁是否是公平的,那么什么是公平锁什么是非公平锁呢?
这我们就不得不深入我们使用的模板方法中看一眼了
/**
* Acquires in exclusive mode, ignoring interrupts.
Implemented
* by invoking at least once {@link #tryAcquire},
* returning on success. Otherwise the thread is
queued, possibly
* repeatedly blocking and unblocking, invoking {@link
* #tryAcquire} until success. This method can be
used
* to implement method {@link Lock#lock}.
*
* @param arg the acquire argument. This value is
conveyed to
* {@link #tryAcquire} but is otherwise
uninterpreted and
* can represent anything you like.
*/
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
//结合我自己写的尝试获取锁的方法
protected boolean tryAcquire(int arg) {
if (compareAndSetState(0,arg)) {
return true;
}
return false;
}
这里大概描述如下:
1、线程一来首先调用 tryAcquire ,在 tryAcquire 中直接CAS获取锁,如果获取不成功通过 addWaiter 加入等待队列,然后走 acquireQueued 让队列中的某个等待线程去获取锁。
2、不公平就体现在这里,线程来了也不先看一下等待队列中是否有线程在等待,如果没有线程等待,那直接获取锁没什么 问题,如果有线程等待就直接去获取锁不就相当于插队么?
那如何实现这种公平性呢?这就不得不探究一下AQS的内部的实现原理了,下面我们依次来看:
1、查看 AbstractQueuedSynchronizer 的类定义,虽然它里面代码很多,但重要的属性就那么几个
public abstract class AbstractQueuedSynchronizer extends
AbstractOwnableSynchronizer
implements java.io.Serializable {
private volatile int state;
private transient volatile Node head;
private transient volatile Node tail;
static final class Node {
//其他不重要的略
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
}
public class ConditionObject implements Condition,
java.io.Serializable {...}
}
结合前面讲的AQS的类文档注释不难猜到,内部类 Node 以及其类型的变量 head 和 tail 就表示 AQS 内部的一个等待队列,而剩下的 state 变量就用 来表示锁的状态。
等待队列应该就是线程获取锁失败时,需要临时存放的一个地方,用来等 待被唤醒并尝试获取锁。再看 Node 的属性我们知道, Node 存放了当前线程的 指针 thread ,也即可以表示当前线程并对其进行某些操作, prev 和 next 说 明它构成了一个双向链表,也就是为某些需要得到前驱或后继节点的算法提供便利。
2、AQS加锁最核心的代码就是如下,我们要来探究它的实现原理
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
它的原理及整个过程我们以图的形式说明如下:
3、原理搞懂了,那如何让自定义的锁是公平的呢?
其实导致不公平的原因就是线程每次调用 acquire 时,都会先去tryAcquire ,而该方法目前的实现时直接去抢锁,也不看现在等待队列中有没有线程在排队,如果有线程在排队,那岂不是变成了插队,导致不公平。
所以现在的解决办法就是,在 tryAcquire 时先看一下等待队列中是否有在排队的,如果有那就乖乖去排队,不插队,如果没有则可以直接去获取锁。
那如何知道线程AQS等待队列中是否有线程排队呢?其实AQS顶层已经实现好了,它提供了一个 hasQueuedPredecessors 函数:如果在当前线程之前有一个排队的线程,则为True; 如果当前线程位于队列的头部( head.next )或队列为空,则为false。
protected boolean tryAcquire(int arg) {
//先判断等待队列中是否有线程在排队 没有线程排队则直接去获取锁
if (!hasQueuedPredecessors() &&
compareAndSetState(0,arg)) {
return true;
}
return false;
}
4、现在已经有公平锁了,但是成年人的世界不是做选择题,而是都想要,自己编写的锁既能支持公平锁,也支持非公平锁,让使用者可以自由选择,怎么办?
其实只要稍微改造一下即可,
public class MyLock implements Lock {
//同步器
private Sync syn ;
MyLock () {
syn = new NoFairSync();
}
MyLock (boolean fair) {
syn = fair ? new FairSync():new NoFairSync();
}
@Override
public void lock() {
//调用模板方法
syn.acquire(1);
}
@Override
public void unlock() {
//调用模板方法
syn.release(0);
}
// Lock接口其他方法暂时先不实现 略
// 实现一个独占同步器
class Sync extends AbstractQueuedSynchronizer{
@Override
protected boolean tryRelease(int arg) {
setState(arg);
return true;
}
}
class FairSync extends Sync {
@Override
protected boolean tryAcquire(int arg) {
//先判断等待队列中是否有线程在排队 没有线程排队则直
接去获取锁
if (!hasQueuedPredecessors() &&
compareAndSetState(0,arg)) {
return true;
}
return false;
}
}
class NoFairSync extends Sync {
@Override
protected boolean tryAcquire(int arg) {
//直接去获取锁
if (compareAndSetState(0,arg)) {
return true;
}
return false;
}
}
}
5、现在锁的公平性问题解决了,但是老板又出了新的需求,要求我们的锁支持可重入,因为它写了如下一段代码,发现一直获取不到锁
static Lock lock = new MyLock();
static void test3() {
lock.lock();
try {
System.out.println("test3 get lock,then do
something ");
test4();
} finally {
lock.unlock();
}
}
static void test4() {
lock.lock();
try {
System.out.println("test4 get lock,then do
something ");
} finally {
lock.unlock();
}
}
那如何让锁支持可重入呢?也就是说如果一个线程持有锁之后,还能继续获取锁,也就是说让锁只对不同线程互斥。
查看 AbstractQueuedSynchronizer 的定义我们发现,它还继承自另一个类: AbstractOwnableSynchronizer
public abstract class AbstractQueuedSynchronizer extends
AbstractOwnableSynchronizer
implements java.io.Serializable {...}
public abstract class AbstractOwnableSynchronizer
implements java.io.Serializable {
private transient Thread exclusiveOwnerThread;
protected final void setExclusiveOwnerThread(Thread
thread) {...}
protected final Thread getExclusiveOwnerThread(){...}
}
看到这我们明白了,原来 AQS 中有个变量是可以保存当前持有独占锁的线程的。那好办了,当我们获取锁时,如果发现锁被持有不要着急放弃,先看看持有锁的线程是否时当前线程,如果是还能继续获取锁。
另外关于可重入锁,还要注意一点,锁的获取和释放操作是成对出现的,就像下面这样
lock
lock
lock
lock
....
unlock
unlock
unlock
unlock
所以对于重入锁不仅要能记录锁被持有,还要记录重入的次数,释放的时候也不是直接将锁真实的释放,而是先减少重入次数,能释放的时候在释放。故此时状态变量 state 不在只有两个取值 0,1 ,某线程获取到锁state=1 ,如果当前线程重入获取只需增加状态值 state=2 ,依次同理,锁释放时释放一次状态值 -1 ,当 state=0 时才真正释放,其他线程才能继续获取锁。
修改我们锁的代码如下:公平非公平在可重入上的逻辑是一样的
public class MyLock2 implements Lock {
//同步器
private Sync syn ;
MyLock2 () {
syn = new NoFairSync();
}
MyLock2 (boolean fair) {
syn = fair ? new FairSync():new NoFairSync();
}
@Override
public void lock() {
//调用模板方法
syn.acquire(1);
}
@Override
public void unlock() {
//调用模板方法
syn.release(1);
}
// Lock接口其他方法暂时先不实现
@Override
public void lockInterruptibly() throws InterruptedException {
}
@Override
public boolean tryLock() {
return false;
}
@Override
public boolean tryLock(long time, @NotNull TimeUnit unit) throws InterruptedException {
return false;
}
@NotNull
@Override
public Condition newCondition() {
return null;
}
// 实现一个独占同步器
class Sync extends AbstractQueuedSynchronizer{
@Override
protected boolean tryRelease(int arg) {
if (Thread.currentThread() != getExclusiveOwnerThread()) {
throw new IllegalMonitorStateException();
}
boolean realRelease = false;
int nextState = getState() - arg;
if (nextState == 0) {
realRelease = true;
setExclusiveOwnerThread(null);
}
setState(nextState);
return realRelease;
}
}
class FairSync extends Sync {
@Override
protected boolean tryAcquire(int arg) {
final Thread currentThread = Thread.currentThread();
int currentState = getState();
if (currentState == 0 ) { // 可以获取锁
//先判断等待队列中是否有线程在排队 没有线程排队则直接去获取锁
if (!hasQueuedPredecessors() && compareAndSetState(0,arg)) {
setExclusiveOwnerThread(currentThread);
return true;
}
}else if (currentThread == getExclusiveOwnerThread()) {
//重入逻辑 增加 state值
int nextState = currentState + arg;
if (nextState < 0) {
throw new Error("Maximum lock count exceeded");
}
setState(nextState);
return true;
}
return false;
}
}
class NoFairSync extends Sync {
@Override
protected boolean tryAcquire(int arg) {
final Thread currentThread = Thread.currentThread();
int currentState = getState();
if (currentState ==0 ) { // 可以获取锁
//直接去获取锁
if (compareAndSetState(0,arg)) {
setExclusiveOwnerThread(currentThread);
return true;
}
}else if (currentThread == getExclusiveOwnerThread()) {
//重入逻辑 增加 state值
int nextState = currentState + arg;
if (nextState < 0) {
throw new Error("Maximum lock count exceeded");
}
setState(nextState);
return true;
}
return false;
}
}
}
并发容器
Java 并发包有很大一部分内容都是关于并发容器的,Java 1.5 之前提供的同步容器虽然也能保证线程安全,但是性能很差,而 Java 1.5 版本之后提供的并发容器在性能方面则做了很多优化,并且容器的类型也更加丰富了。
1、常见的并发容器
Java 中的容器主要可以分为四个大类,分别是 List、Map、Set 和Queue,但并不是所有的 Java 容器都是线程安全的。例如,我们常用的ArrayList、HashMap 就不是线程安全的。
那如何将非线程安全的容器变成线程安全的呢?我们提供几个思路
1、采用面向对象的思想,把非线程安全的容器封装在对象内部,对象提供线程安全的访问方法,以 ArrayList 为例说明如下:
public class SafeList<E> implements List<E> {
//封装非线程安全的ArrayList
List<E> container ;
SafeList() {
container = new ArrayList<E>();
}
SafeList(int initCapacity) {
container = new ArrayList<E>(initCapacity);
}
@Override
public synchronized boolean add(E o) {
return container.add(o);
}
@Override
public synchronized E get(int index) {
return container.get(index);
}
// 其他同理 略
}
当然我们能想到这一点,Java SDK 的开发人员也想到了,所以他们在Collections 这个类中还提供了一套完备的包装类,比如下面的示例代码中,分别把 ArrayList 、 HashSet 和 HashMap 包装成了线程安全的 List 、 Set 和 Map
List<Object> list = Collections.synchronizedList(new ArrayList<>());
Set<Object> set = Collections.synchronizedSet(new HashSet<> ());
Map<Object, Object> map = Collections.synchronizedMap(new HashMap<>());
但是这里要注意的一个问题是:虽然包装后的单个操作都是原子的,但是如果将多个操作进程组合操作,那组合后不一定能包装原子性,比如
static boolean addIfAbsent(String name) {
if (!list.contains(name)) {
list.add(name);
return true;
}
return false;
}
当然这种不管是我们自己包装的,还是 Collections 包装的,都是基于synchionized 关键字实现的,这种通过 shnchronized 实现的容器我们叫做:同步容器,除了这些外还有java提供的 Vector、Stack 、 Hashtable ;对于同步容器要注意的是,虽然当个操作时原子性的,但是组合操作不一定是原子性的,另外同步容器用 shnchronized 保证互斥在性能上的表现并不尽如人意。
因此 Java 在 1.5 及之后版本提供了性能更高的容器,我们一般称为并发容器。
2、并发容器虽然数量非常多,但基本也是这四大类:List、Map、Set 和Queue,下面这幅图基本就是我们能使用到的并发容器
2、ConcurrentHashMap
这里要先来说明一下1.7和1.8中 HashMap 在多线程环境下是如何的线程不安全的?以及 ConcurrentHashMap 是如何解决的?
jdk1.7
1.7中有一个广为人知的哈希表扩容进行数据迁移时采用尾插法导致链表成
环的问题,我们仔细剖析一下。
我们首先来看 put 方法
public V put(K key, V value) {
// 哈希表为空 初始化哈希表
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// hashmap允许key=null
if (key == null)
return putForNullKey(value);
//计算hash值
int hash = hash(key);
// 获取该k-v应该在哈希表中的槽位置
int i = indexFor(hash, table.length);
// 拿到该槽位上的链表,如果链表上有节点的key及hash等于当
前要插入的key及hash,则只需要替换节点的value
for (Entry<K,V> e = table[i]; e != null; e =
e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key ||
key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 向 hash table下标为i的槽位链表中添加一个Entry节点
addEntry(hash, key, value, i);
return null;
}
重点看 addEntry ,它的内部会完成扩容逻辑,扩容就涉及到数据迁移
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果hash table容量超过阈值,则先扩容
if ((size >= threshold) && (null !=
table[bucketIndex])) {
// 完成扩容,容量为原始容量的2倍
resize(2 * table.length);
//重新计算hash和 bucketIndex
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
// 在bucketIndex位置的链表头插入新节点 完成插入
createEntry(hash, key, value, bucketIndex);
}
直接进 resize 方法
void resize(int newCapacity) {
// 原始hash table
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//如果原始容量已到最大容量则不在扩容,直接返回
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 按照新容量创建新的hash table
Entry[] newTable = new Entry[newCapacity];
// 完成数据从 oldTable 到 newTable 的迁移工作
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//变更 table 引用
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor,
MAXIMUM_CAPACITY + 1);
}
其中 transfer 方法是完成数据迁移的
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 遍历 table 的每个hash槽
for (Entry<K,V> e : table) {
//完成bucket链表的迁移
while(null != e) {
//记录next
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//找到在newTable中的新位置
int i = indexFor(e.hash, newCapacity);
//头插发完成迁移 多线程下会早上链表有环
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
这里出现问题的根源是什么呢?我们画图分析如下:
当然这里只是分析了 HashMap 的 put 方法在扩容数据迁移过程中并发导致的链表成环的问题,其实在 put 方法其他地方也会因为在多线程下存在竞态条件而导致线程安全问题,所以保证 HashMap 能在多线程环境下安全的运行势在必行,那都有什么解决办法呢?
首先我们可能想到的就是在 put 方法上加上同步原语 synchroized ,或者开始的时候 lock ,结束的时候 unlock ,这样虽然能解决问题,但是也让多线程下的添加操作只能串行执行了,性能上有损耗,比如:
其实我们仔细思考就会发现,如果多个线程在 put 时不操作同一个hashbucket链表,其实他们之间是不会存在竞争的,因为他们操作的是不同的链表,不存在数据的共享,那既然这样,我们为什么不可以降低锁的粒度来争取并发能力的提升呢?
基于此想法, Doug Lea 提供了 ConcurrentHashMap ,它使用了分段锁(Segment)机制来提高 ConcurrentHashMap 的并发能力并保证并发安全。分段锁其实是一种锁的设计,并不是具体的一种锁,它的核心思想是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率。
我们来看 ConcurrentHashMap 的定义
public class ConcurrentHashMap<K, V> extends
AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_CONCURRENCY_LEVEL = 16;//分段
锁level
final Segment<K,V>[] segments;
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY,
DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity, float
loadFactor, int concurrencyLevel) {
//其他代码略,只贴出核心的一句
Segment<K,V>[] ss = (Segment<K,V>[])new
Segment[ssize];
this.segments = ss;
}
static final class Segment<K,V> extends ReentrantLock
implements Serializable {
transient volatile HashEntry<K,V>[] table;
final V put(K key, int hash, V value, boolean
onlyIfAbsent) {....}
}
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
}
看到这我们大概明白了, ConcurrentHashMap 内部维护了一个 Segment 数组(大小是16),每个 Segment 内部各维护了一个 hash table ,而 Segment是继承自 ReentrantLock 的,也就说 Segment 本身是把锁,它负责锁住自己内部的 hash table 的相关操作,这样当多线程并发操作的时候,只要不在同一个 Segment 下就不会产生竞争操作,同时也说明了 ConcurrentHashMap 最多能允许16个线程并发添加元素,如下图
public class ConcurrentHashMap<K, V> extends
AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_CONCURRENCY_LEVEL = 16;//分段
锁level
final Segment<K,V>[] segments;
public ConcurrentHashMap() {
this(DEFAULT_INITIAL_CAPACITY,
DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
}
public ConcurrentHashMap(int initialCapacity, float
loadFactor, int concurrencyLevel) {
//其他代码略,只贴出核心的一句
Segment<K,V>[] ss = (Segment<K,V>[])new
Segment[ssize];
this.segments = ss;
}
static final class Segment<K,V> extends ReentrantLock
implements Serializable {
transient volatile HashEntry<K,V>[] table;
final V put(K key, int hash, V value, boolean
onlyIfAbsent) {....}
}
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
}
看到这我们大概明白了, ConcurrentHashMap 内部维护了一个 Segment 数组(大小是16),每个 Segment 内部各维护了一个 hash table ,而 Segment是继承自 ReentrantLock 的,也就说 Segment 本身是把锁,它负责锁住自己内部的 hash table 的相关操作,这样当多线程并发操作的时候,只要不在同一个 Segment 下就不会产生竞争操作,同时也说明了 ConcurrentHashMap 最多能允许16个线程并发添加元素,如下图
下面我们来看 ConcurrentHashMap 中 put 方法的实现
public V put(K key, V value) {
Segment<K,V> s;
//不支持value为null
if (value == null)
throw new NullPointerException();
// 求hash值
int hash = hash(key);
// 计算应使用哪个 Segment
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject //
nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) //
in ensureSegment
s = ensureSegment(j);
// 调用Segment的put方法
return s.put(key, hash, value, false);
}
很明显接着直接看 Segment 的 put 方法即可!!!
final V put(K key, int hash, V value, boolean
onlyIfAbsent) {
// 获取Segment锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key,
value, first);
int c = count + 1;
if (c > threshold && tab.length <
MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
//操作完成释放锁
unlock();
}
return oldValue;
}
看完了 ConcurrentHashMap 的 put 操作,下面我们来看一下它的 get 操作
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods
to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
//得到Segment偏移量
long u = (((h >>> segmentShift) & segmentMask) <<
SSHIFT) + SBASE;
// 如果下标u处的Segment不为null且该Segment中的table数组不
为null
if ((s =
(Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) !=
null &&
(tab = s.table) != null) {
//遍历HashEntry[] 某个bucket上的链表 获取数据
for (HashEntry<K,V> e = (HashEntry<K,V>)
UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) <<
TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h &&
key.equals(k)))
return e.value;
}
}
return null;
}
我们发现 get 方法是无锁的。
最后 ConcurrentHashMap 面临的一个问题是,它内部其实相当于多个hash table 组成了一个大的 hash table ,当我们在获取 map.size() 时自然是统计ConcurrentHashMap的总元素数量, 那就需要把各个Segment内部的元素数量汇总起来,但是,如果在统计 Segment 元素数量的过程中,已统计过的 Segment 瞬间插入新的元素,这时候该怎么办呢?
我们来看它 size 方法的处理逻辑大致如下:
遍历所有的Segment,把Segment中的元素数量累加起来且把修改次数累加起来,判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,重试次数+1;如果不是。说明没有修改,统计结束。
如果重试次数超过阈值(默认2),则对每一个Segment加锁,再重新统计,再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等,释放锁,统计结束。
public int size() {
// Try a few times to get accurate count. On failure
due to
// continuous async changes in table, resort to
locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) { //重试 次数超过2次,锁住全部的 Segment
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force
creation
}
sum = 0L;
size = 0;//容器size
overflow = false;
// 遍历统计 segments
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
//求该 Segment 中的元素数量 并累加到 size 上 且累加记录修改次数 modCount
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last) //前后两次统计整体的修改次数没
变,
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();//解锁
}
}
return overflow ? Integer.MAX_VALUE : size;
}
这样的操作思想雷同于乐观锁,悲观锁,为了尽量不锁住所有Segment,首先乐观地假设Size过程中不会有修改。当尝试一定次数,才无奈转为悲观锁,锁住所有Segment保证强一致性。
至此1.7中 ConcurrentHashMap 的锁设计就讲解完成了,但是在1.8中不管是对 HashMap 还是 ConcurrentHashMap 都做了很大程度的优化
public int size() {
// Try a few times to get accurate count. On failure
due to
// continuous async changes in table, resort to
locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) { //重试
次数超过2次,锁住全部的 Segment
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force
creation
}
sum = 0L;
size = 0;//容器size
overflow = false;
// 遍历统计 segments
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
//求该 Segment 中的元素数量 并累加到 size 上
且累加记录修改次数 modCount
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last) //前后两次统计整体的修改次数没
变,
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();//解锁
}
}
return overflow ? Integer.MAX_VALUE : size;
}
这样的操作思想雷同于乐观锁,悲观锁,为了尽量不锁住所有Segment,首先乐观地假设Size过程中不会有修改。当尝试一定次数,才无奈转为悲观锁,锁住所有Segment保证强一致性。
至此1.7中 ConcurrentHashMap 的锁设计就讲解完成了,但是在1.8中不管是对 HashMap 还是 ConcurrentHashMap 都做了很大程度的优化.
jdk1.8
首先 HashMap 在1.8中的底层结构有数组,链表,红黑树,引入红黑树是为了解决 hash 冲突变严重时因链表太长而导致性能下降的问题。我们需要来看一下1.8中在 put 时是否还存在1.7的问题?或者有没有什么新的问题?
首先JDK1.8中有个 resize 函数,它完成扩容及数据迁移工作。在进行数据迁移时采用的时的是尾插法,头插法会改变链表中元素原本的顺序,以至于在并发场景下导致链表成环的问题,而尾插法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题,下面时1.8中 resize 函数的源码
final HashMap.Node<K,V>[] resize() {
HashMap.Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold; //threshold在table未初始化时存储的是构造方法中tableSizeFor(initialCapacity)的值,如果未带参数initialCapacity,则是0;**tableSizeFor()方法不懂可以看之前的博客**
int newCap, newThr = 0;
if (oldCap > 0) { //HashMap中添加过元素的情况下oldCap才会大于0,否则未初始化oldTab==null,oldCap==0;
if (oldCap >= MAXIMUM_CAPACITY) { //原来的容量大于等于HashMap最大容量,给临界值赋值为一个四位整型的最大值,大约为MAXIMUM_CAPACITY的两倍(MAXIMUM_CAPACITY=2^30=1073741824,Integer.MAX_VALUE=2147483647)
threshold = Integer.MAX_VALUE;
return oldTab;
//这段代码整体意思就是 因为添加过元素之后,临界值是容量的0.75倍,达到这个值就会扩容,因为容量已经达到最大,所以给临界值设置一个达不到的值,为的就是以后再进行添加元素时,不会再扩容;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) //添加过元素的扩容,直接容量和临界值扩大两倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr; //未添加元素的时,进行初始化,因为oldThr=threshold存储的是构造方法中tableSizeFor(initialCapacity)的值,所以这里newCap得到的值是初始化容量大小
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
//未调用initialCapacity的有参构造方法,进行默认大小赋值
}
if (newThr == 0) { //当上述代码执行第二个条件语句时,newThr未进行赋值,所以在这里赋值;
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
//其实就是容量newCap的0.75倍
}
threshold = newThr;//重新给临界值赋值,第一次调用resize()过后都不在存储初始容量
@SuppressWarnings({"rawtypes","unchecked"})
HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
table = newTab;
if (oldTab != null) { //把oldTab上的元素移动到newTab上
for (int j = 0; j < oldCap; ++j) {
HashMap.Node<K,V> e;
if ((e = oldTab[j]) != null) {//遍历oldTab
oldTab[j] = null;
if (e.next == null)//如果oldTab[j]上只存在一个节点,直接挂到newTab上
newTab[e.hash & (newCap - 1)] = e;//[e.hash & (newCap - 1)]计算下标,不懂可参考之前的博客
else if (e instanceof TreeNode)//如果是树节点,则进行树处理,有点复杂,不展开多讲,有兴趣自己去了解。嘻嘻~
((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order 这里是对oldTab[j]存在不止一个节点时,进行处理
HashMap.Node<K,V> loHead = null, loTail = null;//这两个节点是维护一个位置未发生变化的链表(维护oldTab[j]下位置不变的节点)
HashMap.Node<K,V> hiHead = null, hiTail = null;//这两个节点是维护一个位置发生变化的链表(维护oldTab[j]下位置变化的节点)
//因为原来的元素位置可能保持不变,可能是原来位置加上原来的oldTab长度:
//hash(不变情况): 0000 0000 0000 0010
//oldCap(16)-1: 0000 0000 0000 1111
//newCap(32)-1: 0000 0000 0001 1111
//hash(变化情况): 0000 0000 0001 0010
//按位与运算,我们可以明显的看出,不变情况的两种计算的结果是[2],而位置变化情况两种计算结果分别是[2],[18];
HashMap.Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {//把位置不变的节点直接挂在链表最后
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { //把位置变化的节点直接挂在链表最后
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;//直接把维护位置不变的链表头结点挂在newTab[j]上
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;//直接把维护位置变化的链表头结点挂在newTab[j + oldCap]上
}
}
}
}
}
return newTab;
}
下面我们来看1.8中 ConcurrentHashMap 是如何保证线程安全的!!!首先要明确一个结论,在jdk1.8中
1、 ConcurrentHashMap 放弃了 Segment 而直接使用 synchronized ,很
多操作都是基于CAS,
2、锁的粒度比之前更细,并发能力提升更高。
我们直接找到 ConcurrentHashMap 中 put 方法的源码来看
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new
NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();//初始化 hash table
else if ((f = tabAt(tab, i = (n - 1) & hash)) ==
null) { //f是bucket中头节点
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value,
null))) // 初始化bucket中第一个元素,采用cas方式
break; // no lock when
adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);// 对bucket进行
transfer操作
else {
V oldVal = null;
synchronized (f) { //锁住bucket中头节点
if (tabAt(tab, i) == f) {
if (fh >= 0) { //对链表操作
binCount = 1;
for (Node<K,V> e = f;; ++binCount)
{
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null &&
key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value; //更新
value
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>
(hash, key,
value, null);//尾插
break;
}
}
}
else if (f instanceof TreeBin) { //对
红黑树操作
Node<K,V> p;
binCount = 2;
if ((p =
((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);//树化
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
可以看到,大部分都是CAS操作,加锁的部分是对桶的头节点进行加锁,也就是锁住每一个bucket,锁粒度很小,
为什么1.8中不用ReentrantLock而用synchronized ?
1、减少内存开销:使用ReentrantLock则需要节点继承AQS来获得同步支持,增加内存开销
2、在竞争不是特别激烈的情况下, synchronized 由于偏向锁,轻量级锁的原因,不用将等待线程挂起, 偏向锁甚至不用自旋,而 ReentrantLock 由于内部使用的是 AQS 机制,即使竞争很少,也会有线程进入等待队列,线程park ,需要等待唤醒 unpark ,这需要带来线程上下文切换的开销;而现在锁粒度本来就很低,所以使用 synchronized 刚刚好!
线程池
虽然在 Java 语言中创建线程看上去就像创建一个对象一样简单,只需要new Thread() 就可以了,但实际上创建线程远不是创建一个对象那么简单。创建对象,仅仅是在 JVM 的堆里分配一块内存而已;而创建一个线程,却需要调用操作系统内核的 API,然后操作系统要为线程分配一系列的资源,这个成本就很高了,所以线程是一个重量级的对象,应该避免频繁创建和销毁,应对的解决方案自然就是线程池。
Java SDK 并发包提供了很多线程池的实现,譬如:ThreadPoolExecutor , ScheduledThreadPoolExecutor , ForkJoinPool等等,但是这些池化资源的实现跟我们普通意义上的池化资源思想不太一样,一般意义上的池化资源,都是下面这样,当你需要资源的时候就调用 acquire()方法来申请资源,用完之后就调用 release() 释放资源(回收到池)。
class XXXPool{
// 获取池化资源
XXX acquire() {
}
// 释放池化资源:回收到池中
void release(XXX x){
}
}
若你带着这个固有模型来看并发包里线程池相关的工具类时,会很遗憾地发现它们完全匹配不上,因为Java 提供的线程池里面压根就没有这样的方法。
其实java中的线程池是一种:生产者 - 消费者模式
线程池的使用方是生产者,线程池本身是消费者,在下面的示例代码中,我们创建了一个非常简单的线程池 ThreadPool ,可以通过它来理解线程池的工作原理
public class C00_ThreadPool {
//利用阻塞队列实现生产者-消费者模式
BlockingQueue<Runnable> workQueue;
//保存内部工作线程
List<WorkerThread> threads = new ArrayList<>();
// 构造线程池
C00_ThreadPool(int poolSize ){
this.workQueue = new LinkedBlockingQueue<>(poolSize);
// 创建工作线程
for(int idx=0; idx<poolSize; idx++){
WorkerThread work = new WorkerThread("ThreadPool-"+idx);
work.start();
threads.add(work);
}
}
// 提交任务到线程池
void execute(Runnable command){
try {
workQueue.put(command);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 工作线程负责消费任务,并执行任务
class WorkerThread extends Thread{
WorkerThread(){};
WorkerThread(String name){
super(name);
}
public void run() {
//循环取任务并执行
while(true){
Runnable task = null;
try {
task = workQueue.take();
task.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
C00_ThreadPool pool = new C00_ThreadPool(3);
for (int i=0;i<10;i++) {
pool.execute(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+" is working");
}
});
}
}
}
当然这只是线程池最基本的一个工作原理,Java 并发包里提供的线程池,远比我们上面的示例代码强大得多,当然也复杂得多。
ThreadPoolExecutor
Java 提供的线程池相关的工具类中,最核心的是 ThreadPoolExecutor,我们首先来看它的类体系及构造
public class ThreadPoolExecutor extends AbstractExecutorService{
private static final RejectedExecutionHandler
defaultHandler = new AbortPolicy();
//核心的构造函数,其他构造函数都是调用该构造函数
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable>
workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler
handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null ||
handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
}
线程池核心参数介绍:
- corePoolSize:核心线程数量
1:线程池刚创建时,线程数量为0,当每次执行 execute 添加新的任务时会在线程池创建一个新的线程,直到线程数量达到 corePoolSize 为止。
2:核心线程会一直存活,即使没有任务需要执行,当线程数小于核心线程数时,即使有线程空闲,线程池也会优先创建新线程处理
3:设置 allowCoreThreadTimeout=true (默认false)时,核心线程超时会关闭
-
workQueue:阻塞队列
1:当线程池正在运行的线程数量已经达到 corePoolSize ,那么再通过execute 添加新的任务则会被加 workQueue 队列中,在队列中排队等待执行,而不会立即执行。
一般来说,这里的阻塞队列有以下几种选择:
ArrayBlockingQueue , LinkedBlockingQueue , SynchronousQueue -
maximumPoolSize:最大线程数
1:当池中的线程数 >=corePoolSize ,且任务队列已满时。线程池会创建
新线程来处理任务
2:当池中的线程数 =maximumPoolSize ,且任务队列已满时,线程池会拒
绝处理任务而抛出异常; -
keepAliveTime:线程空闲时间
1:当线程空闲时间达到 keepAliveTime 时,线程会退出,直到线程数量=corePoolSize 2:如果 allowCoreThreadTimeout=true ,则会直到线程数量=0 -
threadFactory:线程工厂,主要用来创建线程
-
rejectedExecutionHandler:任务拒绝处理器,两种情况会拒绝处理 任务
1:当线程数已经达到 maxPoolSize ,且队列已满,会拒绝新任务
2:当线程池被调用 shutdown() 后,会等待线程池里的任务执行完毕,再shutdown 。如果在调用 shutdown() 和线程池真正 shutdown 之间提交任务,会拒绝新任务
3:当拒绝处理任务时线程池会调用 rejectedExecutionHandler 来处理这个任务。如果没有设置默认是 AbortPolicy ,另外在 ThreadPoolExecutor类有几个内部实现类来处理这类情况
ThreadPoolExecutor.AbortPolicy :丢弃任务并抛出
RejectedExecutionException 异常。
ThreadPoolExecutor.CallerRunsPolicy :由调用线程处理该任务
ThreadPoolExecutor.DiscardPolicy :也是丢弃任务,但是不抛出异常
ThreadPoolExecutor.DiscardOldestPolicy :丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
execute详解
ThreadPoolExecutor 的最基本使用方式就是通过 execute 方法提交一个Runnable 任务,首先看图理解 execute 的执行逻辑
我们再来看该方法的源码实现:
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
//判断工作线程数小于coreSize,addWork,指派任务command,且 core=true
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
//线程池还在运行则将任务加入 workQueue
if (isRunning(c) && workQueue.offer(command)) {
//再检查一下状态
int recheck = ctl.get();
//如果线程池已经终止,移除任务,执行拒绝策略
if (! isRunning(recheck) && remove(command))
reject(command);
//如果没有可用线程了,addWorker 不指派任务task,但会被放 入workers,进而从workQueue获取任务执行。
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//workQueue已满,任务添加不进去, addWorker指派任务command但 core=false;创建新的线程执行任务直到线程数达到maximumPoolSize
else if (!addWorker(command, false))
//添加失败 执行拒绝策略
reject(command);
}
其中添加任务,线程的创建显得比较重要,我们来看 addWorker 方法
private boolean addWorker(Runnable firstTask, boolean core) {
//第一步:计算线程数,不符合条件打回false
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
for (;;) {
//线程池的线程数是不可能设置任意大的,即 maximumPoolSize也有个上限:CAPACITY = (1 << 29) - 1 : 536,870,911
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}
//第二步:创建新 Worker放入线程集合 HashSet<Worker> workers,并启动Worker绑定的线程
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
//线程启动时执行的第一个任 务,看一下它的构造
final Thread t = w.thread;//线程绑定的Runnable就是 Worker
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);//添加Worker 到 workers
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start();//线程启动,Worker的run方法得以执行
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
下面就需要来看一下每个 Worker 线程启动后都做了哪些事情,直接定位到Worker 的 run 方法中:
public void run() {
runWorker(this);
}
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
//获取任务并执行:先看自己有没有携带task,如果没有则去 workQueue 获取,没有任务则当前线程退出。
while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
这里我们来关心一下任务的获取 getTask()
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
int wc = workerCountOf(c);
// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
/**
* 从 workQueue 获取任务 ,两种情况:
* 1、allowCoreThreadTimeOut=false
* 1.1: workcount > corePoolSize 工作
线程空闲 keepAliveTime 没有任务则退出
* 1.2:core thread 永不退出
* 2、allowCoreThreadTimeOut=true,core
thread 也会退出
*/
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}
经典面试
1、线程池是如何保证线程不被销毁的呢?
如果队列中没有任务时,核心线程会一直阻塞在获取任务的方法,直到返回任务。而任务执行完后,又会进入下一轮 work.runWork()中循环
2、那么线程池中的线程会处于什么状态?
RUNNABLE,WAITING,因为要么就在执行任务,要么就在阻塞等待获取任务
3、核心线程与非核心线程有区别吗?
没有。被销毁的线程和创建的先后无关。即便是第一个被创建的核心线
程,仍然有可能被销毁
验证:看源码,每个work在runWork()的时候去getTask(),在getTask内部,并没有针对性的区分当前work是否是核心线程或者类似的标记。只要判断works数量超出core,就会调用poll(),否则take()
Executors
Executors提供了一些创建线程池的工具方法
1:Executors.newSingleThreadExecutor()
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L,
TimeUnit.MILLISECONDS,
new
LinkedBlockingQueue<Runnable>()));
}
corePoolSize和maximumPoolSize都为1,也就是创建了一个固定大小是1的线程池,workQueue是new LinkedBlockingQueue < Runnable >()是一种无界阻塞队列,队列的大小是Integer.MAX_VALUE,可以认为是队列的大小不限制。
由此可以得出通过该方法创建的线程池,每次只能同时运行一个线程,当有多个任务同时提交时,那也要一个一个排队执行
2:Executors.newFixedThreadPool(int nThreads)
public static ExecutorService newFixedThreadPool(int
nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L,
TimeUnit.MILLISECONDS,
new
LinkedBlockingQueue<Runnable>());
}
创建了一个固定大小的线程池,可以指定同时运行的线程数量为
nThreads。
3:Executors.newCachedThreadPool()
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L,
TimeUnit.SECONDS,
new
SynchronousQueue<Runnable>());
}
构造一个缓冲功能的线程池,配置corePoolSize=0,
maximumPoolSize=Integer.MAX_VALUE,keepAliveTime=60s,以及一个无限容量的阻塞队列 SynchronousQueue,因此任务提交之后,将会创建新的线程执行;线程空闲超过60s将会销毁
4:Executors.newScheduledThreadPool(int corePoolSize)
public static ScheduledExecutorService
newScheduledThreadPool(int corePoolSize) {
return new
ScheduledThreadPoolExecutor(corePoolSize);
}
public static ScheduledExecutorService
newScheduledThreadPool(
int corePoolSize, ThreadFactory threadFactory)
{
return new
ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
}
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory)
{
super(corePoolSize, Integer.MAX_VALUE, 0,
TimeUnit.NANOSECONDS,
new DelayedWorkQueue(), threadFactory);
}
构造有定时功能的线程池,配置corePoolSize,无界延迟阻塞队列DelayedWorkQueue;maximumPoolSize=Integer.MAX_VALUE,由于DelayedWorkQueue是无界队列,所以这个值是没有意义的
知识小贴士:注意:阿里巴巴编程规约中对于并发处理有几条强制要求如下:
线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor
的方式,这样的处理方法让写的同学更加明确线程池的运行规则,规避资源耗
尽的风险;对于Executors返回线程池对象的弊端有:
FixedThreadPool和SingleThreadPool允许请求的任务等待队列长度为Integer.MAX_VALUE,可能会堆积大量的请求任务,从而导致OOM
CachedThreadPool和ScheduledThreadPool允许创建的最大线程数为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM
从阿里的编码规约来看推荐我们自主的创建ThreadPoolExecutor,那对于线程池最核心的几个参数应该如何选取呢?线程池参数不能胡乱制定否则对服务的性能影响很大,需要根据任务的性质来决定,我们主要参考以下两个方面
1: I/O密集型:
CPU使用率较低,程序中会存在大量I/O操作占据时间,导致线程空余时间出来,线程个数为CPU核数的两倍。当其中的线程在IO操作的时候,其他线程可以继续用CPU,提高了CPU的利用率
2:CPU密集型:
CPU使用率较高(也就是一些复杂运算,逻辑处理),所以线程数一般只需要CPU核数的线程就可以了(实践中会选择core+1)。 这一类型的在开发中多出现的一些业务复杂计算和逻辑处理过程中。线程个数为CPU核数。这几个线程可以并行执行,不存在线程切换到开销,提高了CPU的利用率的同时也减少了切换线程导致的性能损耗