一、线程安全
1、Java 语言中的线程安全
Java 中共享的数据分为 5 类:
1)不可变:不可变的对象一定是线程安全的,不需要再采取任何的线程安全保障措施
2)绝对线程安全:不管运行时环境如何,调用者都不需要额外的同步措施
3)相对线程安全:一个对象单独的操作是线程安全的,但是对于一些特定顺序的操作,就可能需要在调用端使用额外的同步手段来保证调用的正确性。比如 Hashtable,Vector
4)线程兼容:对象本身不是线程安全的,但是可以通过同步手段来保证并发时安全。比如 ArrayList,HashMap
5)线程对立:无论是否采取同步措施,都无法在多线程环境中并发使用
2、线程安全的实现方法
1)互斥同步
最基本的互斥手段就是 Synchronized 关键字。
- 在 synchronized 关键字经过编译之后,会在同步块前后形成 monitorenter 和 monitorexit 这两个字节码指令
- 指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象,可以是对象的 reference,或者对象实例或 class 对象
- 执行 moniterenter 指令时,先尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有锁,那么锁计时器+1
- 执行 moniterexit 指令时,锁计数器-1,当计数器=0 时,锁被释放
- synchronized 同步块对同一条线程来说是可重入的,不会出现把自己死锁的问题
- 如果要阻塞或唤醒一个线程,需要从用户态转换到内核态,耗费很高,所以是重量级锁
ReentrantLock:
- lock 和 unlock 配合 try/finally 语句来完成
-
等待可中断:当持有锁的线程长时间不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情
- 公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来排队。synchronized 锁是非公平锁,所有的线程都有机会获得,ReentrantLock 默认也是非公平的。
- 锁绑定多个条件:一个 ReentrantLocl 对象可以同时绑定多个 condition 对象,多次调用 newCondition 方法即可。而 synchronized 中,锁对象的 wait、notify、notifyAll 方法可以实现一个隐含的条件,但是如果要和多于一个的条件做关联,就不得不额外的添加一个锁。
JDK1.5 之前,ReentrantLock 性能好于 Synchronized。
JDK1.6之后,Synchronized 进行了很多优化,所以性能基本持平,建议使用 synchronized。
2)非阻塞同步
互斥同步会带来线程阻塞和唤醒的性能问题,也成为阻塞同步,是一种悲观的并发策略。
非阻塞同步是一种乐观的并发策略。
- 先进行操作,如果没有其他的线程争用共享数据,那操作就成功了。如果有冲突,那么再采取其他补偿措施
- 这种乐观的并发策略的许多实现都不需要将线程挂起,因此这种同步操作被称为非阻塞同步
- 硬件指令集发展之后,有了 CAS 功能,才能采用这种策略
- 原子类的 CompareAndSet、getAndIncrement 方法都使用了 Unsafe 的 CAS 操作
3)无同步方案
如果一个方法不涉及共享数据,那它自然就无须任何同步措施去保证正确性。有些代码天生就是线程安全的。
- 可重入代码:
- 不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入,不调用非可重入方法。
- 如果一个方法的返回是可预测的,只要输入了相同的数据,就能返回相同的结果,那么就是线程安全的
- 线程本地存储:
- java 通过 threadLocal 类来实现线程本地存储功能
二、锁优化
1、自旋锁与自适应自旋
共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。
可以让线程执行一个忙循环(自旋),等待锁定状态的释放,这项技术就是自旋锁。
- 自旋锁虽然避免了线程切换的开销,但是会占用处理器时间
- 如果自旋超过了限定的次数还没有获得锁,那就会挂起线程
- JDK1.6 中引入了自适应的自旋锁,意味着自旋时间不再固定了,根据上一次的结果进行推断
2、锁消除
在 JIT 运行时,对一些 代码上要求同步但是检测到不可能存在竞争的锁 进行消除。
主要依据是逃逸分析,如果一段代码中,堆上所有的数据都不会逃逸,不会被其他线程访问,那么就可以把他们当做栈上数据,是私有的,无需加锁。
3、锁粗化
如果一系列连续操作对同一个对象反复加锁和解锁,或者加锁操作出现在循环中,那么这时候可以把锁扩展到操作序列之外。
4、轻量级锁
传统的锁是重量级锁。轻量级锁是在没有多线程竞争的情况下,减少重量级锁使用操作系统互斥产生的性能消耗。但是在有竞争的情况下,除了互斥量的开销,还额外发生了 CAS 操作,因此会比重量级锁更慢。
HotSpot 虚拟机的对象(对象头部分)的内存布局:
对象头信息分为两部分:
- Mark Word:一部分用于存储对象自身的运行时数据,比如 hashcode,GC 分代年龄
- 另一部分存储指向方法区对象类型数据的指针
在代码进入同步块时,看一下轻量级加锁的步骤是什么样的:
- 当同步对象没有被锁定时,虚拟机会首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的 mark word 拷贝。
-
然后虚拟机尝试通过 CAS 将 Mark Word 更新为指向 Lock Record 的指针,
- 成功的话,就获取了这个对象的锁,此时 Mark Word 标志位变为 01,表示处于轻量级锁定状态
- 失败的话,虚拟机会检查 Mark Word 是否指向当前线程的栈帧,如果当前线程已经持有锁,则继续执行,否则说明这个对象的锁被其他线程抢占了。
- 如果有两条线程以上来争用同一个锁,那么轻量级锁就会膨胀为重量级锁,标志位变为“10”,Mark Word 存储的就是指向重量级锁的纸质怎,后面等待锁的线程也会阻塞
轻量级锁的解锁步骤:
- 如果对象的 Mark Word 仍然指向线程的锁记录 Lock Record,那么用 CAS 逆向换回来,
- 替换成功的话就说明解锁了
- 不成功的话说明其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程
5、偏向锁
偏向锁是为了在无竞争的情况下把所有的同步操作都消除掉,包括 CAS。
- 当锁对象第一次被线程获取的时候,虚拟机会把头对象的标志位设置为“01”,即偏向模式
- 然后使用 CAS 操作把这个线程 ID 记录在对象的 Mark Word 之中
- 如果 CAS 成功,那么持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作
- 当另一个线程尝试去获取这个锁的时候,偏向模式就宣告结束。然后将标志位回复到“01”或者“00“
- 然后进入轻量级锁的执行步骤