JVM线程安全与锁优化
java语言中的线程安全等级
- 不可变: final修饰的基本数据类型。即为不可变。
- 绝对线程安全:不管运行时环境如何,调用者都不需要任何额外同步措施。(java中一般达不到)。
- 相对线程安全:即为通常意义上的线程安全。对象单次的操作是线程安全的。比如:hashtable,vector等。
- 线程兼容:通常意义上的线程不安全。本身不安全,可以通过在调用段使用同步手段保证。比如:hashmap,arraylist等。
- 线程对立:无论如何都不能并发使用。(危险,java中一般不存在。)
线程安全的实现方法
1. 互斥同步
最常见的并发保障手段。java中最常用的是使用synchronized
关键字进行同步。
synchronized
synchronized具体实现:
synchronized关键字经过javac编译后,会在同步块的前后形成monitorenter和monitorexit两个字节码指令。线程所持有的锁根据synchronized修饰的类型决定。在执行monitorenter时,先要尝试获取对象的锁,如果没锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器增加一,执行monitorexit后,计数器减一。一旦计数器值为0,锁随即被释放。如果获取对象锁失败,那么线程就被阻塞(Block),直到请求锁定的对象被持有它的线程释放。
synchronized特点:
- 可重入
- 无法强制剥夺,或终端等待
- 重量级: 需要操作系统从用户态(user mode)转为核心态(kernel mode),消耗很大。
java.util.concurrent.locks.Lock
重入锁(ReentrantLock)是J.U.C中的Lock接口的一种实现。特点为:
- 等待可中断
- 公平锁:默认也不公平,可以设置为公平,但那样会使得性能急剧下降。
- 锁绑定多个条件。synchronized锁只可以绑定单个条件。
对比synchronized锁与ReentrantLock锁:
synchronized锁 | ReentrantLock锁 | |
---|---|---|
重入 | 可重入 | 可重入 |
公平 | 不公平 | 公平/不公平 (可设定) |
等待中断 | 不可停止等待 | 可以停止等待 |
条件 | 单条件 | 多条件 |
使用 | 简单易用,虚拟机保证自动释放 | 需要手动保证在finally快中释放锁,否则可能永远不会释放。 |
2.非阻塞同步
互斥同步属于悲观的,而非阻塞同步是基于冲突检测的乐观并发策略。重点介绍比较并交换(CAS)
CAS
有三个操作数,分别是:内存地址V,旧的预期值A,新的预期值B。当V满足A,才会使用B更新V。否则不更新。 最终返回V的旧值。
java中的应用:使用
AtomicInteger
代替int
, 使用AtomicInteger :: incrementAndGet()
方法,来代替int a++;
可以保证原子性!
而IncrementAndGet
方法的实现其实就是,在死循环中,不断调用compareAndSet
(java实现的CAS)方法!
3. 无同步方案
-
可重入代码:又称纯代码。可以在任何时刻终端他,去执行另外一段代码,而后继续执行后,不会出现任何错误。特征:不依赖全局变量。判断依据:如果一个方法的返回结果是可以预测的,只要输入了相同输入,就能返回相同结果,即满足可重入性。(就是数学中的一对一函数嘛。。。)
-
线程本地存储:对于某个变量,线程独享。 使用ThreadLocal类来实现。
每一个线程的Thread对象中都有一个Thre adLocalMap对象,以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值。
扩展:
SimpleDataformate
为一个线程不安全的工具类,当多个线程同时使用,可能使得a线程刚要使用,b线程却把值clear的问题。重点理解:
- 为什么 ThreadLocalMap 放在 Thread ,而不放在 ThreadLocal 中呢?
试想一下,如果 Thread 结束了生命周期,而此时 ThreadLocal 还没有结束生命周期,由于 ThreadLocal 引用了 ThreadLocalMap,ThreadLocalMap 引用了 Thread,使得 Thread 无法被垃圾收集器回收,导致内存泄漏。 - 为什么 Entry 对 ThreadLocal 是弱引用(WeakReference)?
试想一下,如果 ThreadLocal 结束了生命周期,而此时 Thread 还没有结束生命周期(从线程池中获取线程),由于 Thread -> ThreadLocalMap -> entry -> key(ThreadLocal) 的引用存在,使得 ThreadLocal 无法被回收,有了弱引用之后,ThreadLocal 只能存活到下次GC。 - value 为什么不能被回收?
Entry 中的 value 是被 Entry 强引用的,所以即便 value 的生命周期结束了,value 也是无法被回收的,从而导致内存泄露。 - 那要如何回收 value 呢?
手动remove。
- 为什么 ThreadLocalMap 放在 Thread ,而不放在 ThreadLocal 中呢?
锁优化
1. 自旋锁与自适应自旋:稍等下,不阻塞。
由于互斥同步中对性能影响最大的是阻塞的实现,线程的挂起和恢复都需要用户态转内核态。但是另一个线程往往很快就会释放锁。因此,我们让得不到锁的线程“稍等一会儿”,但不放弃处理器的执行时间。自旋锁默认关闭,需要手动打开。自旋次数由用户手动指定。
即排队的时候,让用户先在旁边等一下,而不走远,这样就不会浪费用户走来走去的时间了。
自适应自旋:在自旋锁中自旋次数由用户手动指定,很不明智。我们让虚拟机变聪明,根据以往的经验来决定是否自旋,以及自旋的时间。
就像排队的时候,以往我每次来买煎饼都很快,这次我买煎饼时就在旁边等会儿;如果要去买现炸的糕点,以往来的时候都要等很久,那么我干脆直接去远处坐着,不会在这旁边站着干等着。
2. 锁消除:不逃逸的数据不锁
有很多数据,虽然被锁上了,但是其实根本不会被共享,那么这个锁会被虚拟机直接消除。(虚拟机可真聪明)
以上就要依靠虚拟机的逃逸分析技术,(听起来很牛的样子,实现虽然我们不懂,但是实际上作用很好理解)。逃逸分析的结果(由低到高的逃逸程度)分为:
- 不逃逸:不被其他方法或线程访问。
- 方法逃逸:作为调用参数传递到其他方法中。
- 线程逃逸:可以被外部线程访问到。
3. 锁粗化:由锁定一个序列,代替连续几个语句频繁锁定
如果一系列操作都是连续对同一个对象加锁解锁,会导致不必要的性能消耗。因此,JVM会把锁同步的范围粗化到整个操作序列的外部。。
4. 轻量级锁:在无竞争的情况下,利用CAS操作避免了互斥量的开销
利用对象头中Mark Word(MW )部分的2bit作标志位。
- 当标志位显示没有被锁定(01)。 把MW复制到线程的栈帧中的锁记录(Lock Record)的位置。
- 尝试使用CAS操作把对象的MW指针,更新指向Lock Record,并且把标志位转为00(轻量级锁定的标识)。
- 如果成功,即可进入同步块。
- 如果不成功:检查MW是否只想当前线程的栈帧(被自己抢了),如果是自己,可以直接进入同步快。如果不是自己,说明被其他线程抢占了。如果两条以上的线程争用一个锁,那么轻量级锁膨胀为重量级锁。等待的线程进入阻塞状态!
- 解锁的过程也是通过CAS操作进行。 如果替换失败说明有其他线程尝试过获取该锁,需要在释放锁的时候,唤醒被挂起的线程。
特点:“绝大部分的锁,在同步周期内不存在竞争”,才使得轻量级锁能提升性能(CAS避免了互斥量的开销)。而如果有竞争,使用CAS反而增加了开销。
5. 偏向锁:在无竞争的情况下,把整个同步都消除掉,进入高速模式
“偏”即为“偏心”,锁会骗你选哪个与第一个获得它的线程。如果接下来锁一直没有别其他线程获得,则持有偏向锁的线程将不需要进行同步。
当锁对象第一次被线程获取时,把偏向模式字段设为1,利用CAS把线程ID记录进MW。之后该线程在进入时,不需要进行任何同步操作。
一旦有其他线程尝试去获取这个锁,则偏向模式结束。如果对象未锁定,则撤销偏向,变成未被锁定的不可偏向对象,如果对象已经被锁定,则变成被轻量级锁定的对象。