共享带来的问题
临界区 Critical Section
一个程序运行多个线程本身是没有问题的 问题出在多个线程访问共享资源 多个线程读共享资源其实也没有问题 在多个线程对共享资源读写操作时发生指令交错,就会出现问题 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。
就比如i++这个操作实际上的JVM指令有四条
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
如果两个线程都执行了i++这个操作,第一个线程读完i还没加完,另外一个线程也读了还没自增的i,那么最终他们写回的结果都是i+1,相当于i只自增了一次。这样就出现了线程安全问题。
synchronized 解决方案
介绍
synchronized俗称【对象锁】,它采用互斥的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁 的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
使用方法:
synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}
注意事项:
- synchronized加在方法上等于加在对象上,因为synchronized是对象锁。
- 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 两个线程不会互斥
- 如果 t1 synchronized(obj) 而 t2 没有加,两个线程也不会胡扯
变量的线程安全分析
成员变量和静态变量是否线程安全?
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全?
- 局部变量是线程安全的
- 但局部变量引用的对象则未必
- 如果该对象没有逃离方法的作用访问,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
常见线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为
- 它们的每个方法是原子的
- 但注意它们多个方法的组合不是原子的
不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的 有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安 全的呢?
因为replace和substring方法原理是创建了一个新的字符串对象。
基于Monitor的Synchronized实现原理
Java 对象头
普通对象
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
数组对象
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
其中 Mark Word 结构为
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | 00 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | 10 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|--------------------|
首先要知道,Java的对象结构,Java对象的组成为对象头,成员变量,方法区。其中对象头中前32比特是Mark Word 就是用来标记这个对象状态的,有hashcode,分代年龄,锁状态等等。其中有几个比特位是放monitor地址的。
知道每个Java对象的对象头中的Mark Word中有一段位置是放monitor地址之后。来看一下加锁流程。
线程1对一个对象加锁,首先加的是轻量级锁,这个轻量级锁并没有使用monitor对象关联锁对象。加轻量级锁的原因是monitor对象是调用操作系统层面的锁,消耗会比较大,所以如果一个线程有同步代码块,但是并不会有并发的情况下就没有必要使用monitor的锁,此时就会加一个基于线程的轻量级锁。加锁的过程就是在这个线程的栈帧的锁记录结构中储存锁对象的地址,然后尝试cas替换Object的MarkWord,让MarkWord的相应位置存储这个线程的地址,然后将锁状态标记为00,表示由该线程给这个对象加上轻量级锁。
接着有一个线程2也来对这个对象加锁,还是一样的,首先它会尝试去加轻量级锁,在尝试cas替换锁对象中MarkWord值的时候发现已经有一个线程加上了锁,那么接下来原对象的Object就会关联一个Monitor对象,就是轻量级锁转化为重量级锁。然后帮Monitor的Owner对象指向之前加锁的线程1,并且让这个线程2进入Monitor的EntryList队列进行等待
偏向锁:
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。 Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现 这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
一个对象创建时:
- 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 - XX:BiasedLockingStartupDelay=0 来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象 的 Thread ID 当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至 加锁线程