1. 线程安全
线程安全的代码必须具备一个特征:
代码本身封装了所有必要的正确性手段,使得调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。
1.1 java中的线程安全
按照安全程度由强到弱排列,java中的共享数据可分为以下5类:
不可变、绝对线程安全、相对线程安全、线程兼容、线程对立
(1)不可变
不可变对象一定是线程安全的,无论是对象的方法还是方法的调用者,都无须采用任何线程安全的保障措施。
如果共享数据是基本数据类型,那么只用加上final修饰就能让它成为不可变的。
如果共享数据是一个对象,那么需要保证对象的行为不会对其状态产生任何影响,最简单的方法就是把对象中所有带有状态的变量都声明为final。
(2) 绝对线程安全
绝对线程安全很难做到,通常情况下,就算一个对象看似绝对线程安全,但是在多线程访问的情况下还是可能出错。
如java.util中的Vector,它的所有方法都是synchronized的,但是外部使用该对象的时候还是得采取一定措施。如果一个线程调用remove方法将Vector中下标为8的元素删除了,那么另外一个线程想用get()读取下标为8的元素的时候就会出现ArrayIndexOutOfBoundException。因为Vector中的同步方法只能保证Vector内部的方法调用是串行的,不能保证下标是同步的,因此必须在外部调用的时候也采取同步措施。
(3) 相对线程安全
相对线程安全就是通常意义下的线程安全,它保证对这个对象的单独操作是线程安全的,如前面的Vector,对它的单独调用是线程安全的,但是进行连续的不同操作的时候,就容易出错。
(4) 线程兼容
这种情况下,对象本身并不是线程安全的,但是调用者可以采用同步手段来保证对象可以在并发情况下安全使用。
(5) 线程对立
线程对立是指无论调用者是否采取同步措施,都无法在多线程环境下并发使用的对象。
1.2 线程安全的实现方法
(1) 互斥同步
同步保证共享数据在同一时刻只被一个线程使用,互斥是同步的一种手段。
第一种互斥就是synchronized关键字,同步块的前后是monitorenter和moniterexit两个字节码指令,这两个指令都需要一个reference类型的参数来制定要锁定和解锁的对象,如果synchronized指定了锁对象,那它就是reference,如果没有明确指定,就根据该方法是实例方法还是类方法来取this对象,或者Class对象来作为锁对象。这种锁是可以重入的,不会出现把自己锁死的情况,即可以连续调用该对象的多个同步方法而不会导致死锁。
另一种方式是用java.util.concurrent中的重入锁ReentrantLock,它相对于synchronized提供了tryLock()等更多的功能,更加灵活。java1.6之后对synchronzied进行了优化,现在synchronized和ReentrantLock的性能基本差不多了。
(2) 非阻塞同步
前面的互斥同步有阻塞和唤醒带来的性能问题(悲观加锁),而非阻塞同步是一种乐观的加锁策略,如果没有共享数据争用,操作就成功了,如果有共享数据的争用,产生了冲突,就再采取补偿措施(通常是不断的重试,直到成功为止)。
乐观并发策略依赖于硬件指令集的发展,因为需要操作和冲突检测这两个步骤具备原子性,硬件保证一个从语义上看起来是多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置(Test and Set)
- 获取并增加(Fetch and Increment)
- 交换(Swap)
- 比较并交换(Compare and Swap CAS)
- 加载链接/条件存储(Load Linked/Store Conditional LL/SC)
后面两条指令是现代处理器才增加的。
CAS的执行:
操作数:变量的内存地址V,旧的预期值A,新值B
执行过程:当且仅当V符合A时,用B去更新V,否则就不更新,但是无论是否更新,都会返回旧值A,这整个过程是一个原子操作。
java中的CAS操作由sun.misc.Unsafe类的compareAndSwapInt()和compareAndSwapLong()等方法包装提供,但是Unsafe不是提供给用户使用的类,因此只能间接使用,java.util.Concurrent包中AtomicInteger的compareAndSet()和compareAndIncrement()方法就使用了Unsafe类的CAS操作。
CAS可能存在ABA问题,如果一个变量开始是A,并且在准备赋值的时候检查到它仍是A,但是它中途可能被修改成B,然后又修改为A,在大部分情况下,ABA问题不会影响程序并发的正确性。
(3) 无同步方案
有些代码天生就是线程安全的:
- 可重入代码
如果一个方法,它的返回结果是可以预测的,只要输入相同的数据,都能得到相同的结果,那它就是可重入的。 - 线程本地存储
线程本地存储将共享数据的可见范围限制在一个线程内,如web模型中的 “一个请求对应一个服务器线程”,java中可以用ThreadLocal类来实现线程本地存储。
2. 锁优化
java1.6之后HotSpot虚拟机团队增加了多个锁优化技术:适应性自旋、锁消除、锁粗化、轻量级锁、偏向锁,它们都是为了在线程间更高效的共享数据,解决竞争问题。
2.1 自旋锁和自适应自旋
互斥同步中的阻塞会影响并发性能,但是其实共享数据的锁定状态只会持续很短的一段时间,为了这一点时间而去挂起并恢复线程不值得,自旋锁的做法就是让后面那个请求锁的线程 先等一下,别急着走(在一个循环中等待) ,而不是马上就挂起,这样,如果之前线程在短时间内处理完成的话,后面那个忙等待的线程就很快能获取锁了,而不是原先的挂起它然后恢复(开销较大)。
这种做法避免了线程切换的开销,但是会占用CPU资源,通常采用自适应自旋,即设置自旋的次数(默认10次),等待10次循环,如果没有等到锁就走人,别浪费太多CPU资源。
2.2 锁消除
锁消除是虚拟机的即时编译器在运行的时候,对一些不必要的锁进行消除,如一段代码被上了锁,但是检测到这段代码之间不存在共享数据的竞争,那么在编译之后就会将这个锁视为多余的,并且消除掉。
2.3 锁粗化
‘原则上是推荐将同步块的范围设置得越小越好,大部分情况下是对的,但是如果一系列的连续操作都是对同一个对象反复加锁和解锁,那么即使没有锁竞争,频繁的互斥操作会导致性能损耗,例如StringBuilder的append()方法,这个方法本身是同步的,如果多次调用append()就会多次给StringBuilder对象加锁和解锁,因此,锁粗化策略会将同步块扩大,将多个连续的append()操作放到一个大的同步块中,避免多次不必要的加锁解锁。
2.4 偏向锁
偏向锁是在无竞争的情况下将整个同步都消除。
jdk1.6会默认开启偏向锁,如果使用了偏向锁,当锁对象第一次被线程获取的时候,虚拟机会把对象头标志位设为01(偏向模式),同时使用CAS操作将这个锁的持有线程ID记录到Mark Word中,如果CAS成功,以后这个线程每次进入这个锁的同步块时,虚拟机都不会在进行任何加锁解锁操作,但是一旦有另外一个线程尝试获取该锁,偏向模式就结束了,如果这时候锁对象没有被之前线程持有,那么对象就变为未锁定状态,如果这时候锁对象仍然被之前线程持有,那么锁对象就升级为轻量级锁。
2.5 轻量级锁
传统的锁是重量级的,轻量级锁不是用来替代重量级锁的,而是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量而产生的性能损耗。它在无竞争的情况下使用CAS操作区消除互斥量。
加锁:
锁对象升级为轻量级锁之后,虚拟机会在当前线程的栈帧中建立一个 锁记录 的空间,用于拷贝并存储锁对象目前的Mark Word,然后将锁对象的Mark Word更新为 指向锁记录的指针,即这个指针指向那个想获取它的线程的锁记录。如果这个CAS操作成功了,那么当前线程就拥有了这个对象锁,并且让锁对象变为 轻量级锁定 状态,如果CAS操作失败,虚拟机就去检查锁对象的Mark Word是否指向当前线程的 锁记录 ,如果是的,说明当前线程已经拥有了这个锁对象,那就直接进入同步块继续执行,否则就说明锁对象被其他线程持有,当前线程就会被挂起。
当有2个以上的线程去竞争同一个锁的时候,轻量级锁就会升级成重量级锁,就得使用互斥量了,锁对象的Mark Word更新为指向互斥量的指针。
解锁:
如果锁对象处于轻量级锁状态,就用CAS操作将锁对象当前的Mark Word 和 线程中的之前拷贝的Mark Word替换回来,如果成功,同步过程就结束,如果失败,说明有其他线程尝试过获取该锁,那就再释放锁的同时唤醒那个被挂起的线程。
附上对象头的Mark Word
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |