并发学习笔记
1.线程生命周期
Java 中线程的状态分为 6 种:
- 初始(NEW):新创建了一个线程对象,但还没有调用 start()方法。
- 运行(RUNNABLE):Java 线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。
该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权,
此时处于就绪状态(ready)。就绪状态的线程在获得 CPU 时间片后变为运行中
状态(running)。 - 阻塞(BLOCKED):表示线程阻塞于锁。
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作
(通知或中断)。 - 超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时
间后自行返回。 - 终止(TERMINATED):表示该线程已经执行完毕。
2.线程的调度
- 协同式线程调度 :
线程执行的时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。优点是实现简单,缺点是容易一个线程长时间占用cpu - 抢占式线程调度 : (JAVA的调度模式)
每个线程执行的时间以及是否切换都由
系统决定。在这种情况下,线程的执行时间不可控,所以不会有「一个线程导致
整个进程阻塞」的问题出现。
3.CAS
在Java中,CAS(Compare and Swap)是一种并发编程的技术,用于实现多线程环境下的原子操作。CAS操作包括三个操作数:内存位置(通常是一个变量的内存地址)、期望的值和新值。CAS操作会比较内存位置的值与期望的值,如果相等,则将新值写入内存位置;如果不相等,则说明其他线程已经修改了内存位置的值,当前线程重新尝试或放弃操作。
CAS的工作原理是利用底层硬件的原子性操作指令,如比较并交换(CMPXCHG)。它不需要使用锁定机制,因此在高并发环境下,相对于传统的锁机制,CAS具有更好的性能。
-
CAS存在的问题
-
自旋时间过长:如果CAS操作失败,线程会一直尝试操作直到成功,这可能导致自旋时间过长,浪费CPU资源。
-
ABA问题:CAS只能检测到内存位置值是否与期望值相等,无法判断期间是否发生过其他变化。例如,线程A读取内存位置的值为A,然后线程B修改为B,最后又修改回A,此时线程A使用CAS操作仍然成功,但可能出现意料之外的结果。
解决方式:
1. 使用AtomicMarkableReference, 他将记录变量是否被更改过,而不会关心更改的次数 2. 使用AtomicStampedReference, 他将记录更改次数.
- 只能针对一个变量:CAS操作只能对单个变量进行原子操作,对于多个变量的组合操作无法保证原子性.解决方式:使用锁,或者将多个变量包装进一个AtomicReference中.
-
3.1 LongAdder 写热点分散
-
如果写操作并发度低,LongAdder操作的是一个volatile修饰的base变量.
-
如果并发度很高,LongAdder通过将内部状态分散到多个单元(Cell)中,以降低并发情况下的竞争。每个单元都有一个独立的值,线程在累加时可以独立操作某个单元,最后将所有单元的值相加得到最终结果。这种分散热点的方式减少了线程之间的竞争,从而提高了并发性能。
注意: LongAdder的sum方法并没有加锁,所以得到的值在高并发的场景下可能会不准确.
4.常用并发同步工具类
CuclicBarrier、Phaser相当于是CountDownLatch的升级版,更加灵活,但是用的比较少这里就不做说明
4.1 ReentrantLock
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量
- 与sunchronized一样都支持可重入
- 主要应用场景时在多线程环境下对共享资源进行独占式访问,以保证数据的一致性和安全性
示例代码:
import java.util.concurrent.locks.*;
public class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ReentrantLockDemo demo = new ReentrantLockDemo();
// 创建10个线程,每个线程执行1000次increment()操作
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
demo.increment();
}
}).start();
}
// 等待所有线程执行完毕
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 输出结果
System.out.println("Count: " + demo.getCount());
}
}
4.2 Condition
Condition的await()和signal()方法用于线程的等待与唤醒,与Object的wait和notify方法很相似.调用Condition的await()和signal()方法,都必须在lock保护之内。
示例代码
import java.util.concurrent.locks.*;
public class ConditionDemo {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private boolean isProduced = false;
public void produce() {
lock.lock();
try {
// 等待消费者消费
while (isProduced) {
condition.await();
}
// 生产物品
System.out.println("Producing...");
// 标记为已生产
isProduced = true;
// 通知消费者
condition.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void consume() {
lock.lock();
try {
// 等待生产者生产
while (!isProduced) {
condition.await();
}
// 消费物品
System.out.println("Consuming...");
// 标记为未生产
isProduced = false;
// 通知生产者
condition.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ConditionDemo demo = new ConditionDemo();
// 创建生产者线程
Thread producerThread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
demo.produce();
}
});
// 创建消费者线程
Thread consumerThread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
demo.consume();
}
});
// 启动线程
producerThread.start();
consumerThread.start();
// 等待线程执行完毕
try {
producerThread.join();
consumerThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
4.3 Semaphore
Semaphore可以用于控制并发访问的线程数,以及限制某些操作的同时执行数。(与gateway的令牌桶算法有些相似,但是限制的是并发的线程数量)
Semaphore维护了一个计数器,线程可以通过调用acquire()方法来获取Semaphore中的许可证,当
计数器为0时,调用acquire()的线程将被阻塞,直到有其他线程释放许可证;线程可以通过调用
release()方法来释放Semaphore中的许可证,这会使Semaphore中的计数器增加,从而允许更多的
线程访问共享资源。
示例代码:
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
public static void main(String[] args) {
// 创建一个Semaphore对象,初始值为3,表示允许同时运行的线程数量为3个
Semaphore semaphore = new Semaphore(3);
// 创建10个线程,每个线程会调用doWork()方法
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
try {
// 线程运行前先获取一个许可,如果没有可用的许可,线程就会被阻塞
semaphore.acquire();
doWork();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 线程运行结束后释放一个许可,让其他线程可以获取许可运行
semaphore.release();
}
});
thread.start();
}
}
public static void doWork() throws InterruptedException {
// 模拟线程执行任务
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + " finished work.");
}
}
4.4 CountDownLatch
CountDownLatch使用给定的计数值(count)初始化。await方法会阻塞直到当前的计数值
(count),由于countDown方法的调用达到0,count为0之后所有等待的线程都会被释放,并且随
后对await方法的调用都会立即返回。这是一个一次性现象 —— count不会被重置。
CountDownLatch主要有两个方法:countDown()和await()。countDown()方法会将计数器减1,await()方法会阻塞当前线程,直到计数器的值为0。
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
// 创建一个CountDownLatch对象,初始值为3,表示有3个线程需要等待
CountDownLatch latch = new CountDownLatch(3);
// 创建3个线程
for (int i = 0; i < 3; i++) {
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " started work.");
try {
// 模拟线程执行任务
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " finished work.");
// 线程完成任务后调用countDown()方法,将计数器减1
latch.countDown();
});
thread.start();
}
// 等待计数器的值变为0,即所有线程都完成任务
latch.await();
//三个线程全执行完毕
System.out.println("All threads have finished their work.");
}
}
读写锁
读写锁ReadWriteLock,顾名思义一把锁分为读与写两部分,读锁允许多个线程同时获得,因为读操作本身是线程安全的。而写锁是互斥锁,不允许多个线程同时获得写锁。并且读与写操作也是互斥的。读写锁适合多读少写的业务场景。
ReentrantReadWriteLock
使用注意事项:
- 读锁不支持条件变量
- 重入时升级不支持:持有读锁的情况下去获取写锁,会导致获取永久等待
- 重入时支持降级: 持有写锁的情况下可以去获取读锁
示例代码:
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private Lock r = readWriteLock.readLock();
private Lock w = readWriteLock.writeLock();
// 读操作上读锁
public Data get(String key) {
r.lock();
try {
// TODO 业务逻辑
}finally {
r.unlock();
}
}
// 写操作上写锁
public Data put(String key, Data value) {
w.lock();
try {
// TODO 业务逻辑
}finally {
w.unlock();
}
}
ReentrantReadWriteLock的锁降级
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指持有(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。锁降级可以帮助我们拿到当前线程修改后的结果而不被其他线程所破坏,防止更新丢失。
使用示例:
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
private volatile boolean update = false;
public void processData() {
readLock.lock();
if (!update) {
// 必须先释放读锁
readLock.unlock();
// 锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
// TODO 准备数据的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 锁降级完成,写锁降级为读锁
}
try {
//TODO 使用数据的流程(略)
} finally {
readLock.unlock();
}
}
性能更高的读写锁StampedLock
如果我们深入分析ReentrantReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
为了进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock。
StampedLock和ReentrantReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入!在原先读写锁的基础上新增了一种叫乐观读(Optimistic Reading)的模式。该模式并不会加锁,所以不会阻塞线程,会有更高的吞吐量和更高的性能。
它的设计初衷是作为一个内部工具类,用于开发其他线程安全的组件,提升系统性能,并且编程模型也比ReentrantReadWriteLock 复杂,所以用不好就很容易出现死锁或者线程安全等莫名其妙的问题。
StampLock三种访问模式
- Writing(独占写锁):writeLock 方法会使线程阻塞等待独占访问,可类比ReentrantReadWriteLock 的写锁模式,同一时刻有且只有一个写线程获取锁资源;
- Reading(悲观读锁):readLock方法,允许多个线程同时获取悲观读锁,悲观读锁与独占写锁互斥,与乐观读共享。
- Optimistic Reading(乐观读):这里需要注意了,乐观读并没有加锁,也就是不会有 CAS 机制并且没有阻塞线程。仅当当前未处于 Writing 模式 tryOptimisticRead 才会返回非 0 的邮戳(Stamp),如果在获取乐观读之后没有出现写模式线程获取锁,则在方法validate返回 true ,允许多个线程获取乐观读以及读锁,同时允许一个写线程获取写锁。
在使用乐观读的时候一定要按照固定模板编写,否则很容易出 bug,我们总结下乐观读编程模型的模板:
public void optimisticRead() {
// 1. 非阻塞乐观读模式获取版本信息
long stamp = lock.tryOptimisticRead();
// 2. 拷贝共享数据到线程本地栈中
copyVaraibale2ThreadMemory();
// 3. 校验乐观读模式读取的数据是否被修改过
if (!lock.validate(stamp)) {
// 3.1 校验未通过,上读锁
stamp = lock.readLock();
try {
// 3.2 拷贝共享变量数据到局部变量
copyVaraibale2ThreadMemory();
} finally {
// 释放读锁
lock.unlockRead(stamp);
}
}
// 3.3 校验通过,使用线程本地栈的数据进行逻辑操作
useThreadMemoryVarables();
}
使用场景和注意事项
- 对于读多写少的高并发场景 StampedLock的性能很好,通过乐观读模式很好的解决了写线程“饥饿”的问题,我们可以使用StampedLock 来代替ReentrantReadWriteLock ,但是需要注意的是 StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。
- StampedLock 写锁是不可重入的,如果当前线程已经获取了写锁,再次重复获取的话就会死锁,使用过程中一定要注意;
- 悲观读、写锁都不支持条件变量 Conditon ,当需要这个特性的时候需要注意;
- 如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。所以,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。
5 并发容器
5.1 概览
- CopyOnWriteArrayList
对应的非并发容器:ArrayList
目标:代替Vector、synchronizedList
原理:利用高并发往往是读多写少的特性,对读操作不加锁,对写操作,先复制一份新的集合,在新的集合上面修改,然后将新集合赋值给旧的引用,并通过volatile 保证其可见性,当然写操作的锁是必不可少的了。
注意: 如果数组过大,复制的时候占用的内存会多,可能会引起GC,使用前需要评估集合大小.鉴于其复制的属性,该List无法保证读取到的数据是实时的.
-
CopyOnWriteArraySet
对应的非并发容器:HashSet
目标:代替synchronizedSet
原理:基于CopyOnWriteArrayList实现,其唯一的不同是在add时调用的是CopyOnWriteArrayList的addIfAbsent方法,其遍历当前Object数组,如Object数组中已有了当前元素,则直接返回,如果没有则放入Object数组的尾部,并返回。 -
ConcurrentHashMap
对应的非并发容器:HashMap
目标:代替Hashtable、synchronizedMap,支持复合操作
原理:JDK6中采用一种更加细粒度的加锁机制Segment“分段锁”,JDK8中采用CAS无锁算法。 -
ConcurrentSkipListMap
对应的非并发容器:TreeMap
目标:代替synchronizedSortedMap(TreeMap)
原理:Skip list(跳表)是一种可以代替平衡树的数据结构,默认是按照Key值升序的。
5.2 数据结构
-
HashTable
直接上锁,效率差
-
JDK1.7 中的ConcurrentHashMap
结构是用Segments数组 + HashEntry数组 + 链表实现的
采用分段锁的思想,对单个键值对上锁
-
JDK1.8中的ConcurrentHashMap
改用了和HashMap一样的结构操作,也就是数组 + 链表 + 红黑树结构,对单个键值对做cas操作,如果发生hash冲突,使用synchronized加锁,操作链表数据
链表转化为红黑树需要满足2个条件:
链表的节点数量大于等于树化阈值8
Node数组的长度大于等于最小树化容量值64
5.3 HashMap相关源码笔记
暂未整理
5.4 跳表
跳表是一种基于有序链表的数据结构,支持快速插入、删除、查找操作,其时间复杂度为O(log n),比普通链表的O(n)更高效。
https://cmps-people.ok.ubc.ca/ylucet/DS/SkipList.html
跳表的特性有这么几点:
- 一个跳表结构由很多层数据结构组成。
- 每一层都是一个有序的链表,默认是升序。也可以自定义排序方法。
- 最底层链表(图中所示Level1)包含了所有的元素。
- 如果每一个元素出现在LevelN的链表中(N>1),那么这个元素必定在下层链表出现。
- 每一个节点都包含了两个指针,一个指向同一级链表中的下一个元素,一个指向下一层级别链表中的相同值元素。
跳表查找元素图例
6. 线程池
6.1 线程池的五种状态以及状态流转
线程池有五种状态:
- RUNNING:会接收新任务并且会处理队列中的任务
- SHUTDOWN:不会接收新任务并且会处理队列中的任务
- STOP:不会接收新任务并且不会处理队列中的任务,并且会中断在处理的任务(注意:这里是设置中断标记,而非终止任务执行)
- TIDYING:所有任务都终止了,线程池中也没有线程了,这样线程池的状态就会转为TIDYING,一旦达到此状态,就会调用线程池的terminated()
- TERMINATED:terminated()执行完之后就会转变为TERMINATED
这五种状态并不能任意转换,只会有以下几种转换情况:
- RUNNING -> SHUTDOWN:手动调用shutdown()触发,或者线程池对象GC时会调用finalize()从而调用shutdown()
- (RUNNING or SHUTDOWN) -> STOP:调用shutdownNow()触发,如果先调shutdown()紧着调+ shutdownNow(),就会发生SHUTDOWN -> STOP
- SHUTDOWN -> TIDYING:队列为空并且线程池中没有线程时自动转换
- STOP -> TIDYING:线程池中没有线程时自动转换(队列中可能还有任务)
- TIDYING -> TERMINATED:terminated()执行完后就会自动转换
6. 扩展
- java中线程池创建出来之后里面是没有线程的,如果想线程池创建好之后就把核心线程创建出来,需要调用executor.prestartAllCoreThreads() 来预启动所有核心线程