二、线程安全
-
定义:
- “当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的” ——Brian Goetz《Java Concurrency In Practice》
-
线程安全代码的特征:
- 代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无需关心多线程的问题,更无需自己采取任何措施来保证多线程的正确调用
1、Java语言中的线程安全(5类数据)
-
“线程安全”问题的讨论对象:Java语言中各种操作共享的数据
- “安全程度”由强至弱:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立
-
(1)不可变
-
特征:不可变对象一定是线程安全的
- 无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施
-
实现:使用
final
关键字- 对于基本数据类型:定义时使用
final
修饰即可 - 对于引用数据类型(对象):需要保证对象的行为不会对其状态产生任何影响 -> 将对象中带有状态的变量都声明为
final
- 对于基本数据类型:定义时使用
-
-
(2)绝对线程安全
-
特征:完全满足线程安全的定义
-
实现:一般很难满足
-
-
(3)相对线程安全
-
特征:
- 是通常意义上所讲的线程安全
- 它需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施
- 但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性
-
实现:
-
Java API中标注的大多数线程安全的类都不是绝对线程安全,而是相对线程安全。它们虽然在内部做了同步处理,调用时有的仍需要额外的同步措施
-
eg:Vector是线程安全的容器,它的
add()
、get()
和size()
这类方法都是被synchronized
修饰的,但在调用时仍要同步- 比如在另一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用,再用i访问数组就会抛出
ArrayIndexOutOfBoundsException
- 比如在另一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用,再用i访问数组就会抛出
-
-
-
(4)线程兼容
-
特征:指对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中可以安全使用
-
实现:
-
一般说一个类不是线程安全的,一般就是这种情况
-
Java API中大部分的类都是属于线程兼容的
- eg:ArrayList和HashMap
-
-
-
(5)线程对立
-
特征:无论调用端是否采取同步措施,都无法在多线程环境中并发使用的代码
-
实现:
-
由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码应尽量避免
-
eg:Thread类的
suspend()
(已废弃)和resume()
方法。如果有两个线程同时持有一个线程对象,一个尝试去中端线程,另一个尝试去恢复线程,如果并发进行,无论是否同步,目标线程都存在死锁的风险(suspend()
的中断和resume()
的恢复是同一个线程的情况)
-
-
2、线程安全的实现方法(3种同步方案)
-
(1)互斥同步(阻塞同步)
-
定义:是常见的一种并发正确性保障手段。互斥是因,同步是果;互斥是方法,同步是目的。
-
同步:指在多个线程并发访问共享数据时,保证共享数据是同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用
-
互斥:实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式
-
-
缺点:
- 进行线程阻塞和唤醒会带来性能问题
-
实现:
- 1⃣
synchronized
关键字 - 2⃣
java.util.concurrent
包中的重入锁(ReentrantLock):
- 1⃣
-
synchronized
实现过程:-
synchronized
关键字经过编译之后,会在同步块前后分别形成monitorenter
和monitorexit
两个字节码指令 -
这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象
情境 reference参数 Java程序中的 synchronized
明确指明了对象参数被指明对象的reference 未明确指定对象参数 根据 synchronized
修饰的是实例方法还是类方法,去获取对应的对象实例或Class对象来作为锁对象 -
monitorenter
指令执行前需要先尝试获取对象的锁:情境 处理 如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁 将锁的计数器 +1 在执行 monitorexit
指令时将锁的计数器 -1 当计数器为 0 时 锁被释放 获取对象锁失败 当前线程要阻塞等待,直到对象锁被另一个线程释放
-
-
monitorenter
和monitorexit
的行为描述注意点:-
1⃣
synchronized
同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题 -
2⃣ 同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入
-
-
synchronized
是Java语言中一个重量级(Heavyweight)的操作- 原因:Java 的线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,而状态转换需要耗费很多的处理器时间,有可能比用户代码执行的时间还长
-
可重入锁(ReentrantLock)新增多个高级功能
名称 定义 特性 / 实例 等待可中断 指当持有锁的线程长期不是放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情 此特性对处理执行时间非常常的同步块很有帮助 公平锁 指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁(非公平锁则不能保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁) synchronized
中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁锁绑定多个条件 指一个 ReentrantLock 对象可以同时绑定多个 Condition 对象 在 synchronized
中,锁对象的wait()
和notify()
或notifyAll()
方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁,而 ReentrantLock 只需多次调用newCondition()
方法即可
-
-
(2)非阻塞同步
-
引出:互斥同步是一种悲观并发策略,总是认为如果不去做正确的同步措施就会出问题
- 无论共享数据是否真的会出现竞争,它都要进行加锁、用户核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作
-
基于冲突检测的乐观并发策略
-
前提:硬件指令集的发展(需要操作和冲突检测这两个操作具备原子性 -> 依靠硬件保证一个从语义上看起来需要多次操作的行为指通过一条处理器指令就能完成 -> CAS
指令 (目前仍存在“ABA”问题)) -
内容:先进行操作,如果没有其他线程振勇共享数据,那操作就成功;如果共享数据有争用,那就再采取其他的补偿措施(最常见的补偿措施是不断重试,直到成功)
-
结果:这种实现不需要把线程挂起,因此这种同步操作也被称作非阻塞同步(Non-Blocking Synchronization)
-
-
-
(3)无同步方案(不涉及共享数据的代码天生就是线程安全的)
-
1⃣ 可重入代码(Reentrant Code)
-
定义:这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它自身),而在控制权返回后,原来的程序不会出现任何错误
-
特征:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的
-
-
2⃣ 线程本地存储(Thread Local Storage)
-
定义:如果一段代码中所需要的数据必须与其他代码共享,且这些共享代码能保证在同一个线程中执行,那么就可以把共享数据的可见范围限制在同一个线程之内
-
实现:
java.lang.ThreadLocal
类 -> 每一个线程的 Thread 对象中都有一个ThreadLocalMap
对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode
为键,以本地线程变量为值的键值对
-
-
三、锁优化
- 锁优化的目的:为了在线程之间更高效的共享数据,以及解决竞争问题
1、自旋锁与自适应自旋
-
自旋锁概念理解:
-
问题引出:互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,会给系统的并发性能带来很大压力
- 实践证明共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得
-
思路:如果物理机器有一个以上的处理器,能让两个或以上的线程同时执行,就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁
-
解决:为了让线程等待,只需让线程执行一个忙循环(自旋),这项技术就是自旋锁
-
-
自旋锁的特性(优缺点)及使用时机
-
特性:
-
自旋锁默认是关闭的
-
自旋等待不能代替阻塞
-
自旋等待的时间是有一定限度的,如果自旋超过了限定次数仍然没有成功获得锁,就应当使用传统方式挂起线程(默认自旋次数为10次)
-
-
优点:
- 自旋等待避免了线程切换的开销
-
缺点:
- 自旋等待要占用处理器时间
-
使用时机:
- 如果锁被占用的时间很短,自旋等待效果很好
- 如果锁被占用的时间很长,那么自旋线程只会白白消耗处理器资源,带来性能伤的浪费(所以有自旋限度的概念)
-
-
自适应自旋
-
定义:自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
情境 结果 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程中正在运行中 那么虚拟机就会认为这次自旋也很有可能再次成功,进而他将允许自旋等待持续相对更长的时间(大于10次) 如果对于某个锁,自旋很少成功获得过 那么在以后要获取这个锁时可能省略掉自旋过程,以避免浪费处理器资源
-
2、锁消除
-
定义:
- 指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被监测到不可能存在共享数据竞争的锁进行消除
-
判定依据:
- 逃逸分析的数据支持:如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把他们当作栈上数据对待,认为他们是线程私有的
3、锁粗化
- 定义:如果虚拟机探测到有一系列连续的操作都对同一个对象反复加锁和解锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,只加锁一次
- 避免频繁的进行互斥同步操作导致的不必要性能损耗
4、轻量级锁
-
目的:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗(提升同步性能)
- 轻量级锁并不是用来代替传统的锁机制的(重量级锁)
- 依据: “对于绝大部分的锁,在整个同步周期内都是不存在竞争的”
- 这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
-
加锁过程:与对象头的Mark Word相关
-
1⃣ 在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 拷贝(官方把这份拷贝加上了一个 Displaced 前缀,即 Displaced Mark Word)
-
2⃣ 然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位 (Mark Word 的最后 2bit)将转变为 “00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图 12-4 所示。
-
3⃣ 如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧
- 如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象以及被其他线程线程抢占了。
- 如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,所标志的状态变为 “10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
-
-
解锁过程:通过 CAS 操作来进行
- 如果对象的 Mark Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来
- 如果替换成功,整个同步过程就完成了
- 如果替换失败,说明有其他线程尝试过获取该锁,那就要释放锁的同时,唤醒被挂起的线程
- 如果对象的 Mark Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来
-
CAS(Compare and Swap):比较并交换
- Java中的同步器是基于CAS技术实现的
5、偏向锁
-
目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能
- 如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。
-
“偏”的理解;
- 它的意思是这个锁会偏向于第一个获得它的线程
- 如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步
-
原理:
-
假设当前虚拟机启用了偏向锁(启用参数 -XX:+UseBiasedLocking,这是 JDK 1.6 的默认值),那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为 “01”,即偏向模式。同时使用 CAS 操作把获取到这个锁的线程 ID 记录在对象的 Mark Word 之中
- 如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行如何同步操作(例如 Locking、Unlocking 及对 Mark Word 的 Update 等)。
-
当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。
-
根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为 “01”)或轻量级锁定(标志位为 “00”)的状态
-
后续的同步操作就如轻量级锁执行
-
-
偏向锁、轻量级锁的状态转换及对象 Mark Word 的关系
-
优缺点:
-
它同样是一个带有效益权衡(Trade Off)性质的优化
- 它并不一定总是对程序运行有利
-
优点:偏向锁可以提高带有同步但无竞争的程序性能
-
缺点:如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。使用参数 -XX:-UseBiasedLocking 来禁止偏向锁优化反而可以提升性能
-