Java的线程安全性问题主要关注点有3个:可见性、有序性和原子性。
-
voliate
是由于本身语义禁止了指令重排语义 -
synchronized
加重量锁
Java内存模型(JMM)解决了可见性和有序性的问题,而锁解决了原子性的问题。
0. 线程实现
-
内核线程实现
在内核态内进行,需要不断的系统调用(用户态<-->内核态切换)
轻量级线程与内核线程关系 1:1 -
用户线程实现(控制过于复杂基本不再使用)
在用户态内进行,不需要切态
进程与用户线程关系1:N -
混合实现
轻量级线程与用户线程关系M:N -
Java线程实现
根据操作系统线程模型决定(JVM最新版本使用 轻量级线程LWT
实现)
0.1 Java线程调度
- 协同式线程调度(线程自己控制),容易导致一个线程长期block
- 抢占式线程调度(系统控制 - Java方式),可以通过线程优先级来控制
0.2 Java线程状态与操作系统线程状态
Java线程状态 | 关系 | 操作系统线程状态 |
---|---|---|
New | 创建未启动 | New |
Runable | 执行/等待CPU | Running/Ready |
Waiting | 不分配CPU,等待其它线程显示唤醒 | . |
Timed Waiting | 不分配CPU,自动超时/等待其它线程唤醒 | . |
Blocked | 进入同步区,等待阻塞锁 | Blocked |
Teminaled | 终止线程 | Teminaled |
0.3 线程安全实现
互斥是因,同步是果。互斥是方法,同步是目的
- 互斥同步(synchronized/reentranLock)
- 非阻塞同步(CAS)
- 无同步(可重入代码,线程本地存储 -> 静态方法一般都是可重入)
- 变量被多线程访问,使用volatile声明“异变”
- 变量线程独享,使用ThreadLocal
1. Happens-Before工作内存->主内存交互方式
-
需要顺序,但不需要连续,必须成对出现
1.1 read(主内存读入工作内存) -> load(载入副本)
1.2 store(工作内存存储到主内存) -> write(写入主内存) - assign(赋值)给工作内存赋值后必须同步回主内存,不允许线程无故同步回主主内存
- 初始化一个对象过程 read->load->assign->use->store->write
- lock(线程锁定)只能被一个线程操作,但是可以被同一个线程操作多次,同时要执行同样多次的unlock(线程解锁)
- unlock/lock 需要成对存在
- unlock前需要将工作内存同步回主内存(store->write)
- lock变量,会清空此变量的工作内存信息(read->load)
2. synchronized(内置锁)- 监控器monitor实现
synchronized代码块
在Java会被编译成为monitorenter和monitorexit字节码指令(方法
在常量池method_info结构体里放入标识ACC_SYNCHRONIZED
),需要一个reference类型参数来指明锁定和解锁对象
-
有重入锁的特性在执行
monitorenter
指令时,首先要尝试获取对象的锁->进入monitor,如果这个对象没有被锁定(monitor为空
),或者当前线程已经拥有了那个对象的锁(重入monitor
),把锁的计数器加1,相应地,在执行monitorexit
指令时会将锁计数器减1,当计数器为0时,锁就会被释放 - 如果获取对象锁失败,那当前线程就要加入同步队列,阻塞等待,直到对象锁被另外一个线程释放,线程争取到信号量
- 静态方法同步方式采用的是 Class Lock,非静态方法同步方式采用的是Object Lock
通过对象引用找到同步对象,然后获取对象上的监视器锁
- 进入sync 块后, 清空工作内存, 从主内存加载数据
- 退出 sync 块前, 将工作内存回写主内存
monitorenter和monitorexit 特点是Java内存模型中的lock/unlock更高层的字节码指令:
-
对同一对象实例 是可重入的,不会锁自身
-
sync块前后保证有序性(块内部不保证有序性),因此在
DLC(double-checked locking)sync(Objec.class) { o=new Object();}
时代码块内部存在指令重排,对象初始化分为三步:a. 为o分配栈内空间
b. 执行Object构造函数初始化对象, 在堆中开辟空间
c. 栈空间的值存储堆空间的地址
这时候b和c是可以指令重排,为了保证不被指令重排,应该对o设置为volatile,否则并发的情况下,b还没有执行完就分配了堆地址, 内部数据是错误的.
- 可以使用lazy initialization holder解决初始化问题,使用延迟加载,而线程安全使用静态类的
加载和初始化
保证
private static class ResourceHolder {
public static Resource resource = new Resource();
}
public static Resource getResource() {
return ResourceHolder .resource;
}
尽可能的使用synchronized,易于优化, JVM 保证释放锁
不足之处:
- 不能中断阻塞, 没有timeout机制,不能停止等待,必须到成功
- 不能跨越多个对象
- 只能是非公平锁
3. ReentrantLock(显式锁)
优点:
- 等待可中断(等待超时)
- 公平/非公平锁
- 可以绑定多个condition条件
- 可以使用非块结构的锁
解决写写
、写读
,保证数据一致性的强
加锁约束,同时也限制了读读
- 公平锁: 严格按照队列, 等待前续节点
- 非公平锁: 使用抢占式, 只要state 变为可用, 抢占到即可(头插法变成head)
state标识状态,AQS在判断状态时,通过用waitStatus>0
表示取消状态,而waitStatus<0
表示有效状态
采用前驱设置状态为SIGNAL通知下一个节点
pred准备好了的方式,此时后继可以park等待唤醒. 插入使用尾插法
3.1 acquire(ReentrantLock使用1个信号量来控制lock/unlock)
- 先尝试获acquire取信号量
- 获取信号量失败,
等待队列
使用使用尾插法队列,此Node是一个双向链表,插入一个waiter node
(使用CAS+loop保证线程安全) - 使线程在
等待队列
中一直自旋
到获取到资源后才返回。如果在整个等待过程中被中断
过,则返回true,否则返回false(这个自旋只是等待等待队列
中被被标记为SIGNAL
的同步节点park
后被unpark
,直到所有等待队列
中的节点都被唤醒
并执行) - 获取资源后中
出现中断
,再进行自我中断selfInterrupt()补偿
acquire-release.png
3.2 公平锁/非公平锁
使用队列来实现初始化公平
独占模式:
acquire
使用队列来在不可获取信号量后,入队列等待。信号量为1的互斥区间
共享模式:
acquireShared
信号量为n的的区间,判断是否还有资源,返回-1无资源,否则类似独占模式处理等待队列,在唤醒下一节点的时候有区别,独占模式(只有一个资源)只会修改节点,而共享模式有多个资源,在当前节点修改后还有资源,会循环主动唤醒有效的资源waiter节点,直到等待队列
中所有的waiter都被唤醒一次,至于唤醒后能不能获取资源由自己去争抢
3.3 Condition -> await
reentrantLock->内置有同步队列,condition->内置有等待队列
等待队列(FIFO): 当我们将等待队列中的线程节点加入到同步队列之后,才会唤醒线程。
3.4 等待队列是单向队列、同步队列是双向队列的一些思考
同步队列
要设计成双向
的,是因为在等待队列中,节点唤醒是接力式的,由前一个节点
唤醒它的后一个节点
,如果是由next指针获取下一个节点,是有可能获取失败的,因为虚拟队列每添加一个节点,是先用CAS把tail设置为新节点,然后才修改原tail的next指针到新节点的。因此用next向后遍历是不安全
的,但是如果在设置新节点为tail前,为新节点设置prev,则可以保证从tail往前遍历是安全的。因此要安全的获取一个节点Node的下一个节点,先要看next是不是null,如果是null,还要从tail往前遍历看看能不能遍历到Node
等待队列
是单向
,等待的线程就是等待者,只负责等待,唤醒的线程就是唤醒者,只负责唤醒,因此每次要执行唤醒操作的时候,直接唤醒同步队列的首节点就行了。等待队列的实现中不需要遍历队列,因此也不需要prev指针
4. volatile
volatile在Java会被编译成0x01a3de24:lock addl $0x0,(%esp) ;...f0830424 00
指令, 来指示缓存一致原则
此指令相当于一个内存屏障,在多CPU访问同一块内存屏障(Memory Barrier)的时候,有 总线锁/缓存锁 两种方式来保证只有一个处理器可以修改, 在修改后缓存上的数据后写回内存, 通过缓存一致性原则, 让其他处理器上的数据缓存无效. 强制读取内存来达到 可见性. 而 barrier 的存在禁止了对其进行指令重拍.
不要做 volatile int count=0; count++;
操作,这个不保证原子性
- 保障可见性/有序性
- 不保障原子性
使用场景:
- 写入变量不依赖变量当前值
- 不与其他状态变量共同参与
- 访问变量不需要加其他锁
读有monitorenter语义
写有monitorexit语义
5. (关卡barrier)CyclicBarrier
CyclicBarrier(集结点)设置集中总数. 当一个线程运行barrier.await()时, 如果集中点没有足够多线程达到, 会等待,直到所有的线程都到达了这个点,所有线程才重新运行
CyclicBarrier可
重用,使用reset()
6. (闭锁latch)CountDownLatch / FuntureTask
CountDownLatch(计数器)某线程运行到某个点上之后,只是给某个数值减1而已,该线程继续运行
CountDownLatch.await()
可以有多个线程监听, 不可
重用,无法重置
FutureTask 多线程处理,在 run()
的过程中,线程使用get()
,会一致阻塞
到获得run()的结果
闭锁不阻塞
线程本身,等待的是事件
7. AtomicXXX 原子变量
CAS (Compareand-Swap) 原子操作
这是由硬件
提供原子操作指令
实现的
CAS
采用自旋等待
的方式来解决同步问题,绝大多数的并发都是瞬时
的,等待片刻
即可
锁
是阻塞
的,需要挂起
线程,稍后需要唤醒
进程,大量的中断
,等待
CAS在非激烈竞争
的情况下,开销更小,速度更快
CAS在激烈竞争
的情况下,和锁的性能类似或者更差
java.util.concurrent中实现的原子操作类包括:AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference
Lock-Free算法(乐观锁):Lock-Free 是指能够确保执行它的所有线程中至少有一个能够继续往下执行。由于每个线程不是 starvation-free 的,即有些线程可能会被任意地延迟,然而在每一步都至少有一个线程能够往下执行,因此系统作为一个整体是在持续执行的,可以认为是 system-wide的。所有 Wait-free 的算法都是 Lock-Free 的。
Mutex
由操作系统实现,而atomic
包中的原子操作则由底层硬件直接提供支持。在 CPU 实现的指令集里,有一些指令被封装进了atomic包,这些指令在执行的过程中是不允许中断(interrupt)的,因此原子操作可以在lock-free的情况下保证并发安全,并且它的性能也能做到随 CPU 个数的增多而线性扩展
8. 原子性
一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为原子性(atomicity), CPU 不可能不中断的执行一系列操作,但如果我们在执行多个操作时,能让他们的中间状态对外不可见,那我们就可以宣称他们拥有了”不可分割”的原子性下(我怎么觉得这个更像一致性)
网友评论