本系列文章:
Java并发编程学习之路(一)并发编程三要素、Thread、Runnable、interrupted、join、sleep、yield
Java并发编程学习之路(二)线程同步机制、synchronized、CAS、volatile、final、Lock、AQS
Java并发编程学习之路(三)ReentrantLock、ReentrantReadWriteLock、死锁、原子类
Java并发编程学习之路(四)线程池、FutureTask
Java并发编程学习之路(五)wait/notify/notifyAll、await/signal/signalAll、生产者消费者问题
Java并发编程学习之路(六)ThreadLocal、BlockingQueue、CopyOnWriteArrayList、ConcurrentHashmap
Java并发编程学习之路(七)CountDownLatch、CyclicBarrier、Semaphore、Exchanger、Phaser
Java并发编程学习之路(八)多线程编程例子
线程安全问题的核心在于多个线程会对同一个临界区共享资源进行操作
。锁的解决思路是将对共享资源同一时刻的操作由并发改为串行,但这样会导致效率出现下降,并且这只是一种解决并发问题的方式,不是唯一方式。
另外一种思路是:如果每个线程都使用自己的“共享资源”,各自使用各自的,又互相不影响到彼此即让多个线程间达到隔离的状态,这样也不会出现线程安全的问题。这是一种“空间换时间”的方案,ThreadLocal就是使用这种方式的。
ThreadLocal表示线程的“本地变量”,即每个线程都拥有该变量副本,各用各的,从而避免共享资源的竞争。
要想学习到 ThreadLocal 的实现原理,就必须了解它的几个核心方法,包括怎样存怎样取等。
set
方法用于设置在当前线程中threadLocal变量的值,源码: public void set(T value) {
//1. 获取当前线程实例对象
Thread t = Thread.currentThread();
//2. 通过当前线程实例获取到ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
if (map != null)
//3. 如果Map不为null,则以当前threadLocl实例为key,值为value进行存入
map.set(this, value);
else
//4.map为null,则新建ThreadLocalMap并存入value
createMap(t, value);
}
可以看出:value是存放在ThreadLocalMap里,以当前threadLocal实例为 key。
ThreadLocalMap getMap(Thread t)
方法用于获取ThreadLocalMap实例:
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
threadLocals变量定义在Thread类中:
ThreadLocal.ThreadLocalMap threadLocals = null;
也就是说ThreadLocalMap的引用是作为Thread的一个成员变量,被Thread进行维护的。
在set方法中,当map为空时,会调用createMap(t,value)方法来创建ThreadLocalMap:
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
对 set 方法进行总结:
- 1、通过当前线程对象thread获取该thread所维护的成员变量threadLocalMap;
- 2、若threadLocalMap不为 null,则以threadLocal实例为 key,值为value的键值对存入threadLocalMap;
- 3、若 threadLocalMap为 null 的话,就新建 threadLocalMap,然后再以threadLocal为键,值为 value 的键值对存入即可。
public T get() {
//1. 获取当前线程的实例对象
Thread t = Thread.currentThread();
//2. 获取当前线程的threadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
//3. 获取map中当前threadLocal实例为key的值的entry
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
//4. 当前entitiy不为null的话,就返回相应的值value
T result = (T)e.value;
return result;
}
}
//5. 若map为null或者entry为null的话通过该方法初始化,并返回该方法返回的value
return setInitialValue();
}
get方法的逻辑,和set方法是相反的。setInitialValue源码:
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
initialValue源码:
protected T initialValue() {
return null;
}
此处表明:继承ThreadLocal的子类可以重写该方法,实现赋予初始值的逻辑。
get方法的逻辑:
- 1、通过当前线程 thread 实例获取到它所维护的 threadLocalMap,以当前 threadLocal 实例为 key 获取该 map 中的键值对(Entry);
- 2、若 Entry 不为 null 则返回 Entry 的 value;
- 3、如果获取 threadLocalMap 为 null 或者 Entry 为 null 的话,就以当前 threadLocal 为 Key,value 为 null 存入 map 后,并返回 null。
public void remove() {
//1. 获取当前线程的threadLocalMap
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
//2. 从map中删除以当前threadLocal实例为key的键值对
m.remove(this);
}
ThreadLocalMap是threadLocal的一个静态内部类,threadLocalMap 内部维护了一个Entry类型的table数组:
private Entry[] table;
table数组的长度为2的幂次方。接下来看下Entry:
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
Entry 是一个以 ThreadLocal 为 key,Object 为 value 的键值对。
这里的threadLocal是弱引用,因为Entry继承了 WeakReference,在 Entry 的构造方法中,调用了 super(k)方法就会将 threadLocal 实例包装成一个 WeakReferenece。
Thread,ThreadLocal,ThreadLocalMap,Entry之间的关系:
上图中的实线表示强引用,虚线表示弱引用。
每个线程实例中可以通过threadLocals获取到threadLocalMap,而threadLocalMap实际上就是一个以threadLocal实例为key,任意对象为value的Entry数组。
当为 threadLocal 变量赋值,实际上就是以当前 threadLocal 实例为 key,值为 value 的 Entry 往这个 threadLocalMap 中存放。需要注意的是Entry 中的 key 是弱引用,当 threadLocal 外部强引用被置为 null(threadLocalInstance=null),那么系统 GC 的时候,根据可达性分析,这个 threadLocal 实例就没有任何一条链路能够引用到它,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value 永远无法回收,造成内存泄漏。
如果当前 thread 运行结束,threadLocal,threadLocalMap,Entry 没有引用链可达,在垃圾回收的时候都会被系统进行回收。在实际开发中,会使用线程池去维护线程的创建和复用,比如固定大小的线程池,线程为了复用是不会主动结束的。
ThreadLocalMap底层是用散列表(哈希表)进行实现的。
ThreadLocalMap类中的set方法:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
//根据threadLocal的hashCode确定Entry应该存放的位置
int i = key.threadLocalHashCode & (len-1);
//采用开放地址法,hash冲突的时候使用线性探测
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
//覆盖旧Entry
if (k == key) {
e.value = value;
return;
}
//当key为null时,说明threadLocal强引用已经被释放掉,那么就无法再通过
//这个key获取threadLocalMap中对应的entry,这里就存在内存泄漏的可能性
if (k == null) {
//用当前插入的值替换掉这个key为null的“脏”entry
replaceStaleEntry(key, value, i);
return;
}
}
//新建entry并插入table中i处
tab[i] = new Entry(key, value);
int sz = ++size;
//插入后再次清除一些key为null的“脏”entry,如果大于阈值就需要扩容
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
private final int threadLocalHashCode = nextHashCode();
private static final int HASH_INCREMENT = 0x61c88647;
private static AtomicInteger nextHashCode =new AtomicInteger();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
0x61c88647 这个数是有特殊意义的,它能够保证哈希表的每个散列桶能够均匀的分布。也正是能够均匀分布,所以ThreadLocal选择使用开放地址法来解决hash冲突的问题。
key.threadLocalHashCode & (len-1)
,因为哈希表大小总是为 2 的幂次方,所以与运算等同于一个取模,这样就可以通过 Key 分配到具体的哈希桶中去。为什么取模要通过位与运算?因为位运算的执行效率远远高于了取模运算。nextIndex(i, len)
方法解决 hash 冲突的问题,该方法为: private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
也就是不断往后线性探测,当到哈希表末尾的时候再从 0 开始,成环形。
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
//新数组为原数组的2倍
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
//重新确定entry在新数组的位置,然后进行插入
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}
//设置新哈希表的threshHold和size属性
setThreshold(newLen);
size = count;
table = newTab;
}
resize方法的逻辑:新建一个大小为原来数组长度的两倍的数组,然后遍历旧数组中的 entry 并将其插入到新的 hash 数组中,主要注意的是,在扩容的过程中针对脏 entry 的话会令 value 为 null,以便能够被垃圾回收器能够回收,解决隐藏的内存泄漏的问题。
private Entry getEntry(ThreadLocal<?> key) {
//1. 确定在散列数组中的位置
int i = key.threadLocalHashCode & (table.length - 1);
//2. 根据索引i获取entry
Entry e = table[i];
//3. 满足条件则返回该entry
if (e != null && e.get() == key)
return e;
else
//4. 未查找到满足条件的entry,额外处理
return getEntryAfterMiss(key, i, e);
}
getEntryAfterMiss源码:
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
//找到和查询的key相同的entry则返回
return e;
if (k == null)
//解决脏entry的问题
expungeStaleEntry(i);
else
//继续向后环形查找
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
getEntryAfterMiss的逻辑:通过 nextIndex 往后环形查找,如果找到和查询的 key 相同的 entry 的话就直接返回,如果在查找过程中遇到脏 entry 的话使用 expungeStaleEntry 方法进行处理。
remove方法源码:
private void remove(ThreadLocal<?> key) {
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)]) {
if (e.get() == key) {
//将entry的key置为null
e.clear();
//将该entry的value也置为null
expungeStaleEntry(i);
return;
}
}
}
remove方法的逻辑:通过往后环形查找到与指定 key 相同的 entry 后,先通过 clear 方法将 key 置为 null 后,使其转换为一个脏 entry,然后调用 expungeStaleEntry 方法将其 value 置为 null,以便垃圾回收时能够清理,同时将 table[i]置为 null。
ThreadLocal适用于共享对象会造成线程安全 的业务场景。ThreadLocal经典的使用场景是为每个线程分配一个JDBC连接Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection。
ThreadLocal的使用示例:
public class ThreadLocalTest {
private static ThreadLocal<SimpleDateFormat> sdf = new ThreadLocal<>();
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(2);
for (int i = 0; i<10; i++) {
executorService.submit(new DateUtil("2021-10-" + (i%10+1)));
}
}
static class DateUtil implements Runnable {
private String date;
public DateUtil(String date) {
this.date = date;
}
@Override
public void run() {
if (sdf.get() == null) {
sdf.set(new SimpleDateFormat("yyyy-MM-dd"));
} else {
try {
Date date = sdf.get().parse(this.date);
System.out.println(date);
} catch (ParseException e) {
e.printStackTrace();
} finally {
sdf.remove();
}
}
}
}
}
结果示例:
Mon Oct 04 00:00:00 CST 2021
Wed Oct 06 00:00:00 CST 2021
Sun Oct 03 00:00:00 CST 2021
Sun Oct 10 00:00:00 CST 2021
Sat Oct 09 00:00:00 CST 2021
代码逻辑很简单:如果当前线程不持有 SimpleDateformat 对象实例,那么就新建一个并把它设置到当前线程中;如果已经持有,就直接使用。
ThreadLocal是为了解决对象不能被多线程共享访问的问题,通过threadLocal.set方法将对象实例保存在每个线程自己所拥有的ThreadLocalMap中,这样每个线程使用自己的对象实例,彼此不会影响达到隔离的作用,从而就解决了对象在被共享访问带来线程安全问题
。
再看一下ThreadLocal,ThreadLocalMap,Entry之间的关系:
实线代表强引用,虚线代表的是弱引用,如果threadLocal外部强引用被置为null(threadLocalInstance=null)的话,threadLocal实例就没有一条引用链路可达,很显然在gc(垃圾回收)的时候势必会被回收,因此entry就存在key为null的情况,无法通过一个Key为null去访问到该entry的value
。同时,就存在了这样一条引用链:threadRef->currentThread->threadLocalMap->entry->valueRef->valueMemory,导致在垃圾回收的时候进行可达性分析的时候,value可达从而不会被回收掉,但是该value永远不能被访问到,这样就存在了内存泄漏。
通过threadLocal,threadLocalMap,entry的引用关系看起来threadLocal存在内存泄漏的问题似乎是因为threadLocal是被弱引用修饰的。那为什么要使用弱引用呢?
假设threadLocal使用的是强引用,在业务代码中执行threadLocalInstance==null操作,以清理掉threadLocal实例的目的,但是因为threadLocalMap的Entry强引用threadLocal,因此在gc的时候进行可达性分析,threadLocal依然可达,对threadLocal并不会进行垃圾回收,这样就无法真正达到业务逻辑的目的,出现逻辑错误
。
因为堆中的ThreadLocal实例到Entry之间的引用是弱引用,所以当断开ThreadLocalRef到ThreadLocal实例之间的引用(threadLocalInstance = null)时,Entry和ThreadLocal实例之间的引用就是孤零零的弱引用,这样就可以被GC(一旦JVM发现某个弱引用,就会将其回收)。
假设Entry弱引用threadLocal,尽管会出现内存泄漏的问题,但是在threadLocal的生命周期里(set,getEntry,remove)里,都会针对key为null的脏entry进行处理。
当线程退出时会执行exit方法:
private void exit() {
if (group != null) {
group.threadTerminated(this);
group = null;
}
/* Aggressively null out all reference fields: see bug 4006245 */
target = null;
/* Speed the release of some of these resources */
threadLocals = null;
inheritableThreadLocals = null;
inheritedAccessControlContext = null;
blocker = null;
uncaughtExceptionHandler = null;
}
从源码可以看出当线程结束时,会令threadLocals=null,也就意味着GC的时候就可以将threadLocalMap进行垃圾回收,换句话说threadLocalMap生命周期实际上thread的生命周期相同。
每次使用完ThreadLocal,都调用它的remove()方法,清除数据
。示例: ThreadLocal<M> tl = new ThreadLocal<>();
tl.set(new M());
tl.remove();
在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理
。 Collection集合框架中的各种容器类,如实现ArrayList、HashSet、HashMap等,这些容器类基本上不是线程安全的。除了使用 Collections工具类可以将其转换为线程安全的容器,JDK中还有对应的线程安全的容器,如实现 List 接口的 CopyOnWriteArrayList,实现 Map 接口的 ConcurrentHashMap,实现 Queue 接口的 ConcurrentLinkedQueue等。
在经典的生产者消费者问题中,阻塞队列常常被用到。因为BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:
- 在队列空时,获取元素的线程会阻塞;
- 当队列满时,存储元素的线程会阻塞。
阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程
。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。
BlockingQueue基本操作:
抛异常 | 特殊值 | 阻塞 | 超时 | |
---|---|---|---|---|
插入 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
删除 | remove() | poll() | take() | poll(time,unit) |
获取 | element() | peek() |
BlockingQueue继承于Queue接口,对数据元素的基本操作有:
add(E e) :往队列插入数据,当队列满时,插入元素时会抛出IllegalStateException异常;
offer(E e):当往队列插入数据时,插入成功返回true,否则则返回false。
remove(Object o):从队列中删除数据,成功则返回true,否则为false;
poll():删除数据,当队列为空时,返回null。
element():获取队头元素,如果队列为空时则抛出NoSuchElementException异常;
peek():获取队头元素,如果队列为空则抛出NoSuchElementException异常。
BlockingQueue的特有接口:
put(e):当阻塞队列容量已经满时,往阻塞队列插入数据的线程会被阻塞,直至阻塞队列已经有空余的容量可供使用;
offer(E e, long timeout, TimeUnit unit):若阻塞队列已经满时,同样会阻塞插入数据的线程,直至阻塞队列已经有空余的地方,与put方法不同的是,该方法会有一个超时时间,若超过当前给定的超时时间,插入数据的线程会退出。
take():当阻塞队列为空时,获取队头数据的线程会被阻塞;
poll(long timeout, TimeUnit unit):当阻塞队列为空时,获取数据的线程会被阻塞;如果被阻塞的线程超过了指定时间,该线程会退出。
ArrayBlockingQueue是由数组实现的有界阻塞队列。该队列命令元素FIFO(先进先出)
。因此,队头元素时队列中存在时间最长的数据元素,而队尾数据则是当前队列最新的数据元素。ArrayBlockingQueue可作为“有界数据缓冲区”,生产者插入数据到队列容器中,并由消费者提取。ArrayBlockingQueue一旦创建,容量不能改变。private static ArrayBlockingQueue<Integer> blockingQueue =
new ArrayBlockingQueue<Integer>(10,true);
LinkedBlockingQueue是用链表实现的有界阻塞队列,同样满足FIFO的特性,与ArrayBlockingQueue相比起来具有更高的吞吐量,为了防止LinkedBlockingQueue容量迅速增,损耗大量内存。通常在创建LinkedBlockingQueue对象时,会指定其大小,如果未指定,容量等于Integer.MAX_VALUE
。 阻塞队列最核心的功能是,能够可阻塞式的插入和删除队列元素
。
当前队列为空时,会阻塞消费数据的线程,直至队列非空时,通知被阻塞的线程;当队列满时,会阻塞插入数据的线程,直至队列未满时,通知插入数据的线程(生产者线程)。
ArrayBlockingQueue的主要属性:
/** The queued items */
final Object[] items;
/** items index for next take, poll, peek or remove */
int takeIndex;
/** items index for next put, offer, or add */
int putIndex;
/** Number of elements in the queue */
int count;
/** Main lock guarding all access */
final ReentrantLock lock;
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;
可以看出ArrayBlockingQueue内部是采用数组(items)进行数据存储的,为了保证线程安全,采用的是ReentrantLock。为了保证可阻塞式的插入删除数据利用的是Condition,当获取数据的消费者线程被阻塞时会将该线程放置到notEmpty等待队列中,当插入数据的生产者线程被阻塞时,会将该线程放置到notFull等待队列中。
notEmpty和notFull等重要属性在构造方法中进行创建:
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
//如果当前队列已满,将线程移入到notFull等待队列中
while (count == items.length)
notFull.await();
//满足插入数据的要求,直接进行入队操作
enqueue(e);
} finally {
lock.unlock();
}
}
该方法的逻辑很简单,当队列已满时(count == items.length)将线程移入到notFull等待队列中,如果当前满足插入数据的条件,就可以直接调用enqueue(e)插入数据元素。
enqueue方法源码:
private void enqueue(E x) {
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
//插入数据
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
//通知消费者线程,当前队列中有数据可供消费
notEmpty.signal();
}
enqueue方法的逻辑同样也很简单,先完成插入数据,即往数组中添加数据(items[putIndex] = x),然后通知被阻塞的消费者线程,当前队列中有数据可供消费(notEmpty.signal())。
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
//如果队列为空,没有数据,将消费者线程移入等待队列中
while (count == 0)
notEmpty.await();
//获取数据
return dequeue();
} finally {
lock.unlock();
}
}
take方法也主要做了两步:1. 如果当前队列为空的话,则将获取数据的消费者线程移入到等待队列中;2. 若队列不为空则获取数据,即完成出队操作dequeue。
dequeue方法源码为:
private E dequeue() {
// assert lock.getHoldCount() == 1;
// assert items[takeIndex] != null;
final Object[] items = this.items;
@SuppressWarnings("unchecked")
//获取数据
E x = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length)
takeIndex = 0;
count--;
if (itrs != null)
itrs.elementDequeued();
//通知被阻塞的生产者线程
notFull.signal();
return x;
}
dequeue方法也主要做了两件事情:
- 获取队列中的数据,即获取数组中的数据元素((E) items[takeIndex]);
- 通知notFull等待队列中的线程,使其由等待队列移入到同步队列中,使其能够有机会获得lock,并执行完成功退出。
从以上分析,可以看出put和take方法主要是通过condition的通知机制来完成可阻塞式的插入数据和获取数据。
LinkedBlockingQueue是用链表实现的有界阻塞队列,当构造对象时未指定队列大小时,队列默认大小为Integer.MAX_VALUE。从它的构造方法可以看出:
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
LinkedBlockingQueue的主要属性:
/** Current number of elements */
private final AtomicInteger count = new AtomicInteger();
/**
* Head of linked list.
* Invariant: head.item == null
*/
transient Node<E> head;
/**
* Tail of linked list.
* Invariant: last.next == null
*/
private transient Node<E> last;
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
可以看出与ArrayBlockingQueue主要的区别是,LinkedBlockingQueue在插入数据和删除数据时分别是由两个不同的lock(takeLock和putLock)来控制线程安全的,因此,也由这两个lock生成了两个对应的condition(notEmpty和notFull)来实现可阻塞的插入和删除数据。并且,采用了链表的数据结构来实现队列,Node结点的定义为:
static class Node<E> {
E item;
Node<E> next;
Node(E x) {
item = x; }
}
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
//如果队列已满,则阻塞当前线程,将其移入等待队列
while (count.get() == capacity) {
notFull.await();
}
//入队操作,插入数据
enqueue(node);
c = count.getAndIncrement();
//若队列满足插入数据的条件,则通知被阻塞的生产者线程
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();
}
put方法的逻辑也同样很容易理解,可见注释。基本上和ArrayBlockingQueue的put方法一样。
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
//当前队列为空,则阻塞当前线程,将其移入到等待队列中,直至满足条件
while (count.get() == 0) {
notEmpty.await();
}
//移除队头元素,获取数据
x = dequeue();
c = count.getAndDecrement();
//如果当前满足移除元素的条件,则通知被阻塞的消费者线程
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();
return x;
}
相同点:ArrayBlockingQueue和LinkedBlockingQueue都是通过condition通知机制来实现可阻塞式插入和删除元素,并满足线程安全的特性。
不同点:
ArrayBlockingQueue插入和删除数据,只采用了一个lock,而LinkedBlockingQueue则是在插入和删除分别采用了putLock和takeLock,这样可以降低线程由于线程无法获取到lock而进入WAITING状态的可能性,从而提高了线程并发执行的效率
。CopyOnWriteArrayList可以保证线程安全,保证读读线程之间不会被阻塞,因此CopyOnWriteArrayList被广泛应用于很多读多写少的场景中。
如果简单的使用读写锁来保证线程安全的话,在写锁被获取之后,读写线程被阻塞,只有当写锁被释放后读线程才有机会获取到锁从而读到最新的数据。站在读线程的角度来看,即读线程任何时候都是获取到最新的数据,满足数据实时性。
要进行优化,一种思路是牺牲数据实时性满足数据的最终一致性即可,CopyOnWriteArrayList 就是具备了这种方式的容器。CopyOnWriteArrayList通过Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。
COW通俗的理解是:当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
对COW容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。
CopyOnWriteArrayList内部维护了一个数组:
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
public E get(int index) {
return get(getArray(), index);
}
final Object[] getArray() {
return array;
}
private E get(Object[] a, int index) {
return (E) a[index];
}
get方法实现非常简单:所有的读线程只是会读取数据容器中的数据,并不会进行修改。
public boolean add(E e) {
final ReentrantLock lock = this.lock;
//1. 使用Lock,保证写线程在同一时刻只有一个
lock.lock();
try {
//2. 获取旧数组引用
Object[] elements = getArray();
int len = elements.length;
//3. 创建新的数组,并将旧数组的数据复制到新数组中
Object[] newElements = Arrays.copyOf(elements, len + 1);
//4. 往新数组中添加新的数据
newElements[len] = e;
//5. 将旧数组引用指向新的数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
add 方法的逻辑也比较容易理解,需要注意这么几点:
- 两者都是通过读写分离的思想实现;
2.读线程间是互不阻塞的。
不同点:使用ReentrantReadWriteLock时,对读线程而言,为了实现数据实时性,在写锁被获取后,读线程会等待或者当读锁被获取后,写线程会等待,从而解决“脏读”等问题。也就是说如果使用读写锁依然会出现读线程阻塞等待的情况。
COW则完全放开了牺牲数据实时性而保证数据最终一致性,即读线程对数据的更新是延时感知的,因此读线程不会存在等待的情况。
内存占用问题
数据一致性问题
ConcurrentHashMap是线程安全的Map,利用了锁分段的思想提高了并发度。
JDK1.6版本ConcurrentHashMap的关键要素:
- segment 继承了 ReentrantLock 充当锁的角色,为每一个 segment 提供了线程安全的保障;
- segment 维护了哈希散列表的若干个桶,每个桶由 HashEntry 构成的链表。
JDK1.8的ConcurrentHashMap舍弃了 segment,并且大量使用了synchronized以及CAS无锁操作以保证 ConcurrentHashMap 操作的线程安全性。至于为什么不用 ReentrantLock 而是 Synchronzied 呢?因为synchronzied 做了很多的优化。因此,使用 synchronized 相较于 ReentrantLock 的性能会持平甚至在某些情况更优。同时,底层数据结构改变为采用数组+链表+红黑树的数据形式。
//装载Node的数组,采用懒加载的方式,直到第一次插入数据的时候才会进行初始化
//操作,数组的大小总是为 2 的幂次方。
transient volatile Node<K,V>[] table;
//扩容时使用
private transient volatile Node<K,V>[] nextTable;
//该属性用来控制 table 数组的大小
private transient volatile int sizeCtl;
//在ConcurrentHashMapde的实现中可以看到大量的U.compareAndSwapXXXX的方法
//去修改ConcurrentHashMap的一些属性
private static final sun.misc.Unsafe U;
U的获取是在静态代码块中:
static {
try {
U = sun.misc.Unsafe.getUnsafe();
.......
} catch (Exception e) {
throw new Error(e);
}
}
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
......
}
Node类中很多属性都是用 volatile 进行修饰的,也就是为了保证内存可见性。
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
......
}
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
......
}
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
.....
}
以下是几个常用的利用 CAS 算法来保障线程安全的操作。
//获取table数组中索引为i的Node元素
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
//利用CAS操作设置table数组中索引为i的元素
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
//设置table数组中索引为i的元素
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
ConcurrentHashMap的构造方法:
//构造一个空的map,即table数组还未初始化,初始化放在第一次插入数据时,默认大小为16
ConcurrentHashMap()
//指定map的初始容量
ConcurrentHashMap(int initialCapacity)
//用指定Map的元素填充ConcurrentHashMap
ConcurrentHashMap(Map<? extends K, ? extends V> m)
//指定map的初始容量以及加载因子
ConcurrentHashMap(int initialCapacity, float loadFactor)
//给定map大小,加载因子以及并发度(预计同时操作数据的线程)
ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
public ConcurrentHashMap(int initialCapacity) {
//小于0直接抛异常
if (initialCapacity < 0)
throw new IllegalArgumentException();
//判断是否超过了允许的最大值,超过了话则取最大值,否则再对该值进一步处理
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
//赋值给sizeCtl
this.sizeCtl = cap;
}
当调用构造器方法之后,sizeCtl 的大小应该就代表了 ConcurrentHashMap 的大小,即 table 数组长度。
initTable方法源码:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
//保证只有一个线程正在进行初始化操作
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
// 2. 得出数组的大小
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
// 3. 这里才真正的初始化数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// 4. 计算数组中可用的大小:实际大小n*0.75(加载因子)
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
可能存在一个情况是多个线程同时走到这个方法中,为了保证能够正确初始化,在第 1 步中会先通过 if 进行判断,若当前已经有一个线程正在初始化即 sizeCtl 值变为-1,这个时候其他线程在 If 判断为 true 从而调用 Thread.yield()让出 CPU 时间片。
正在进行初始化的线程会调用 U.compareAndSwapInt 方法将 sizeCtl 改为-1 ,即正在初始化的状态。另外还需要注意的事情是,在第四步中会进一步计算数组中可用的大小即为数组实际大小 n 乘以加载因子 0.75。如果选择是无参的构造器的话,这里在 new Node 数组的时候会使用默认大小为DEFAULT_CAPACITY(16),然后乘以加载因子 0.75 为 12,也就是说数组的可用大小为 12。
put方法源码:
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//1. 计算key的hash值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//2. 如果当前table还没有初始化先调用initTable方法将tab进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//3. tab中索引为i的位置的元素为null,则直接使用CAS将值插入即可
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//4. 当前正在扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//5. 当前为链表,在链表中插入新的键值对
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;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 6.当前为红黑树,将新的键值对插入到红黑树中
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;
}
}
}
}
// 7.插入完键值对后再根据实际大小看是否需要转换成红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//8.对当前容量大小进行检查,如果超过了临界值(实际大小*加载因子)就需要扩容
addCount(1L, binCount);
return null;
}
从整体而言,为了解决线程安全的问题,ConcurrentHashMap 使用了 synchronzied 和 CAS 的方式。
ConcurrentHashMap 是一个哈希桶数组,如果不出现哈希冲突的时候,每个元素均匀的分布在哈希桶数组中。当出现哈希冲突的时候,是标准的链地址的解决方式,将 hash 值相同的节点构成链表的形式,称为“拉链法”,另外,在JDK1.8中为了防止拉链过长,当链表的长度大于 8 的时候会将链表转换成红黑树。table 数组中的每个元素实际上是单链表的头结点或者红黑树的根节点。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
spread 方法进行了一次重 hash 从而大大减小哈希冲突的可能性。该方法主要是将 key 的 hashCode 的低16位和高16位进行异或运算,这样不仅能够使得 hash 值能够分散能够均匀减小 hash 冲突的概率,另外只用到了异或运算,性能也还好。
(fh = f.hash) == MOVED
。
- 在链表中如果找到了与待插入的键值对的 key 相同的节点,就直接覆盖即可;
- 如果直到找到了链表的末尾都没有找到的话,就直接将待插入的键值对追加到链表的末尾。
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;
}
}
这段代码的作用:调用 putTreeVal 方法完成向红黑树插入新节点,同样的逻辑,如果在红黑树中存在于待插入键值对的 Key 相同(hash 值相等并且 equals 方法判断为 true)的节点的话,就覆盖旧值,否则就向红黑树追加新节点。
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
如果当前链表节点个数大于等于 8(TREEIFY_THRESHOLD)的时候,就会调用 treeifyBin 方法将 tabel[i](第 i 个散列桶)拉链转换成红黑树。
get 方法源码:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 1. 重hash
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 2. table[i]桶节点的key与查找的key相同,则直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 3. 当前节点hash小于0说明为树节点,在红黑树中查找即可
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
//4. 从链表中查找,查找到则返回该节点的value,否则就返回null即可
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
首先先看当前的 hash 桶数组节点即 table[i]是否为查找的节点,若是则直接返回;若不是,则继续再看当前是不是树节点?通过看节点的 hash 值是否为小于 0,如果小于 0 则为树节点。如果是树节点在红黑树中查找节点;如果不是树节点,那就只剩下为链表的形式的一种可能性了,就向后遍历查找节点,若查找到则返回节点的 value 即可,若没有找到就返回 null。
JDK1.6、JDK1.7中的 ConcurrentHashmap 主要使用 Segment 来实现减小锁粒度,分割成若干个 Segment,在 put 的时候需要锁住 Segment,get 时候不加锁,使用 volatile 来保证可见性,当要统计全局变量(比如 size)时,首先会尝试多次计算 modcount 来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回 size。如果有,则需要依次锁住所有的 Segment 来计算。
JDK1.8 之前 put 定位节点时要先定位到具体的 segment,然后再在 segment 中定位到具体的桶。而在 1.8 的时候摒弃了 segment的设计,直接针对的是 Node[] tale 数组中的每一个桶,进一步减小了锁粒度。并且防止拉链过长导致性能下降,当链表长度大于 8 的时候采用红黑树的设计。
JDK1.8主要设计上的变化有以下几点:
- 不采用 segment 而采用 node,锁住 node 来实现减小锁粒度。
- 设计了 MOVED 状态 当 resize 的中过程中 线程 2 还在 put 数据,线程 2 会帮助 resize。
- 使用 3 个 CAS 操作来确保 node 的一些操作的原子性,这种方式代替了锁。
- sizeCtl 的不同值来代表不同含义,起到了控制的作用。
- 采用 synchronized 而不是 ReentrantLock。
SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为 map;ConcurrentHashMap 使用分段锁(JDK1.6)来保证在多线程下的性能
。
ConcurrentHashMap(JDK1.6)中则是一次锁住一个桶。ConcurrentHashMap 默认将hash表分为16个桶,诸如 get、put、remove 等常用操作只锁当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有 16 个写线程执行,并发性能的提升是显而易见的。
同时,ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中,当iterator 被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时 new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator线程可以使用原来老的数据
,而写线程也可以并发的完成改变。