1. synchronized
互斥同步主要的问题就算是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。从处理问题的方式上说,互斥同步这种同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,就会出问题,无论共享数据是都真的会出现竞争(实际虚拟机会优化掉很大一部分不必要的加锁),用户态核心态转换,维护锁计数器和检查是否阻塞是否有被阻塞的线程需要唤醒等操作。
基于冲突检测的乐观并发策略,先进行操作,如果没有其他线程竞争用共享数据,操作就成功了,如果共享数据有争用,产生冲突,采取其他的补偿方式(常见的为不断的重试,直到成功为止)这种乐观的并发策略许多实现都不用线程挂起,称这种操作为非阻塞同步。
1.1. 硬件
一个从语义看起来需要多次操作的行为只通过一条处理器指令就能完成。
常用指令
- 测试并设置(Test-and-set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap CAS)
- 加载链接/条件存储(Load-Linked/Store-Conditional LL/SC)
1.2. ABA问题
变量V初次读取时候是A值,并且在赋值时候还是A值,期间存在这种可能:A的值被修改成为B,又将B的值修改成A。J.U.C为了解决这个问题,提供了一个带标记的原子引用类AtomicStampedReference,通过控制变量值的版本来保证CAS的正确性,彻底解决ABA问题,改用同步互斥可能会比原子类更高效。
2. 无锁
无锁即无障碍的运行,所有线程都可以到达临界区,接近于无等待。
2.1. CAS
CAS包含3个参数CAS(V,E,N)V表示要更新的变量,E表示预期值,N表示新值。仅当V等于E时,才会将V的值设为N,如果V值和E值不同,说明已经有其他线程做了更新,当前线程什么都不做。CAS返回当前V的真实值。CAS操作是乐观心态进行的,总是认为自己可以成功完成操作,当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并更新成功,其余均失败,失败的线程不会被挂起,仅仅被告知失败,并允许再次尝试,也允许失败的线程放弃操作,基于这样的原理CAS操作即使没有锁也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
CAS整一个操作过程是一个原子操作,有一条CPU指令完成的,从指令层保证操作可靠,不会被多线程干扰。
2.2. volatile
无锁可以通过cas来保证原子性与线程安全,与volatile之间的区别:当给变量加了volatile关键字,表示该变量对所有线程可见,但不保证原子性。
private volatile int index = 0;
public void method(){
index++;
}
上述执行以下步骤:
- 加载index;
- 对index加1;
- 回写index的值;
- 用内存屏障通知其他线程index的值;
其中前三步是线程不安全的,可能其他线程会对i进行读写。因此任何依赖于之前值的操作,如i++,i=i*10使用volatile都不安全,像get/set方法,boolean这类可以使用volatile。
2.3. Unsafe
unsafe类是在sun,misc包下,可以用于一些非安全的操作,例如:根据偏移量设置值,线程park(),底层CAS操作等等。
2.4. Automic
Atomiclnteger AtomicReference
与AutomicInteger类似,只是里面封装了一个对象,而不是int,对引用进行修改。
AutomicStampedReference
也是封装了一个引用,主要解决ABA问题。
AutomicIntegerArray
支持无锁数组。
AutomicIntegerFiledUpdater
普通变量也享受原子操作。
Updater只能修饰它可见范围内的变量,因为Updater使用反射得到变量,如果变量不可见,会抛出异常。
确保变量被正确的读取,必须是volatile类型的,如果我们原有代码中未申明这个类型,需要申明。
由于CAS操作会通过对象实例中的偏移量直接进行赋值,因此他不支持static字段,如:Unsafe.objectFieldOffest()不支持静态变量。
LockFreeVector
JDK中Vector是加锁的,LockFreeVector无锁。
3. 自旋锁 / 自适应自旋锁
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一颗CPU或以上,能让两个线程同时并行运行,我们可以让后面的线程保持等待,但不放弃处理器的执行时间,看持有锁的线程是否很快就会释放锁。为了让线程等待,需要线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
JDK1.4.2引入,默认关闭,使用-XX:UseSpinning参数开启,JDK1.6默认开启。自旋锁不能代替阻塞,自旋等待本身虽然避免了线程切换的开销,却占用处理器的时间,如果锁被占用的时间很短,自旋的效果好,反之,降低性能。如果自旋锁超过了限定的次数任然没有成功获得锁,使用传统的方式挂起线程,自旋次数默认值10,使用参数-XX:PreBlockSpin更改。
JDK1.6之后引用了自适应的自旋锁。自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,如果在同一个锁对象上,自旋等待刚刚获得成功,并且持有锁的线程正在运行中,虚拟机认为这次自旋也有可能会再次成功,进而允许自旋等待时间次序相对更长的时间,如果某个锁很少获得成功,以后获得这个锁时可能省略自旋过程,避免资源浪费,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确。
4. 消除锁
消除锁是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被监测到不可能存在共享数据竞争的锁进行消除。消除锁的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把他们当做栈上数据对待,任务它们是线程私有的,同步锁自然无需进行。
例如:字符串拼接。String是一个不可变类,对字符串的连接操作总是通过生成新的String对象来进行,Javac对String连接进行优化。
StringBuffer.append()
方法中都有一个同步块,锁就是sb本身,虚拟机观察变量sb,发现它的动态作用域限制在concatString()方法内部。sb对象永远不会逃逸,其他线程无法访问他,虽然有锁也会被消除掉。
5. 粗化锁
编写代码时推荐同步块的作用范围限制尽量小的范围,只在数据共享的实际作用域中才进行同步,为了使得需要同步的操作数量尽量减小,如果存在锁的竞争,等待锁的线程也能尽快拿到锁。
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁和解锁操作出现在循环体中,即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能浪费。例如A:
StringBuffer.append().append()
将锁扩展到最后一个append()操作之后,加锁一次即可。
示例 在一个循环中不停的请求同一个锁。
// 原程序
while(true){
synchronized (lock){
// ... TODO
}
}
// 程序优化
synchronized (lock){
while (true){
// ... TODO
}
}
6. 轻量级锁
JDK1.6之后引入,传统的锁机制称为“重量级锁”,轻量级锁不是代替重量级锁,在没有多线程竞争的前提下,减少重量级锁使用操作系统互斥量产生的性能消耗。
HotSpot头说起,虚拟机的对象头(Object Header)分为两部分信息,1.用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称之为“Mark Word”,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑虚拟机的空间成本,Mark Word被设计成一个非固定的数据结构以便在很小的空间内存存储尽量多的信息,根据对象的状态复用自己的存储空间,如:32位HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32bit空间中的25bit用于存储哈希码,4bit用于存储对象的分代年龄,2bit存储锁标志位,1bit固定0,在其他状态(轻量级锁、重量级锁、GC标记、可偏向)小对象存储内容:
| 存储内容 | 标志位 | 状态 |
|---|---|---|
| 对象哈希码、对象分代年龄 | 01 | 未锁定 |
| 指向锁记录的指针 | 00 | 轻量级锁定 |
| 指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
| 空,不需要记录 | 11 | GC标记 |
| 偏向线程ID、偏向时时间戳、对象分代年龄 | 01 | 可偏向 |
执行过程
- 代码进入同步块的时候,如果此同步对象没有被锁定(锁的标志位为01),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方加了Displaced前缀,即Displaced Mark Word)。
- 虚拟机使用CAS操作尝试将对象Mark Word更新为指向Lock Record的指针。如果这个操作成功,那么线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word最后2bit)转变为00,表示此对象处于轻量级锁定状态。
- 如果更新操作失败,虚拟机首先会检查对象Mark Word是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占。如果有两条以上的线程争用同一个锁,轻量级锁就不再有效,膨胀为重量级锁,标志位为10,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
解锁过程也是用过CAS操作进行,如果mark Word仍然指向着线程的锁记录,用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。轻量级锁能提升程序同步性能的依据是:绝大多数的锁,在整个同步周期内都不存在竞争。这是一个经验数据,如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,如果存在锁竞争,除了互斥量的开销外,额外发生了CAS操作,所以存在竞争的情况下,轻量级锁会比传统的重量级锁更慢。
7. 偏向锁
JDK1.6中引入的一项锁优化,目的消除数据在无竞争情况下的同步原语,提高程序性能,如果说轻量级锁时在无竞争的情况下使用CAS操作去消除同步使用的互斥量,偏向锁就是在无竞争的情况下把整个同步都消除,连CAS操作都消除。
这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要进行同步。
假设当前虚拟机启用了偏向锁(参数-XX:+UseBiasedLocking,JDK1.6默认值),当锁对象第一次被线程获取的时候,虚拟机把对象头标志位设为01,即偏向模式,同时使用CAS操作把获取到这个锁的线程ID记录在对象Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(如:Locking,Unlocking及对Mark Word的Update等)。
当有另外一个线程去尝试获取这个锁时,偏向模式宣布结束。根据锁对象目前是否处于被锁的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作如轻量级锁执行。
偏向锁可以提高带有同步但无竞争的程序性能,同样是一个带有效益权衡(Trade Off)性质的优化,如果程序中大多数的锁总是被多个不同的线程访问,那么偏向锁模式就是多余的,有时候使用参数-XX:UseBiasedLocking来禁止偏向锁优化反而可以提升性能。
8. 锁优化
高效并发从jdk1.5到jdk1.6的一个重要改进,HotSpot虚拟机团队实现锁优化技术。自旋锁与自适应自旋。同步互斥对性能最大的影响就是线程挂起、恢复需要从用户态切换到内核态,切换的过程造成系统消耗。往往锁定代码段执行时间非常短,这个短时间去挂起和恢复是不值得的。所以提出自旋锁的概念,当线程申请获取一个其他线程占用的锁时,这个线程不会立即挂起,而是通过一定次数的循环自旋,这个过程不会释放CPU的控制权,自适应自旋就是根据上一次自旋的结果来决定这一次自旋的次数。
8.1. 减少持有时间
// 原程序
public synchronized void syncMethod(){
// ... TODO
}
// 优化程序
public void syncMethod(){
// ... TODO
synchronized (this){
// ... TODO
}
// ... TODO
}
8.2. 减小粒度
大对象拆成小对象,增加并行度,降低锁竞争,偏向锁,轻量级锁成功率提高。例如ConcurrentHashMap / SynchronizedMap
- Collections.SynchronizedMap
本质在读写Map上都加了锁,高并发下性能一般。
- ConcurrentHashMap
内部使用区分Segment来表示不同的部分,每个区就是一个小的hashtable,各自都有锁,修改发生在不同的分区,可以并发的进行,把一个整体分成16个Segment,最高支持16个线程并发修改。
代码中运用了很多volatile声明共享变量,第一时间获取修改的内容,性能高。
8.3. 读写分离锁替代独占锁
ReadWriteLock读写锁分离,读多写少提升系统并发能力
- 读-读不互斥:读之间不阻塞。
- 读-写互斥:读阻塞写,写阻塞读。
- 写-写互斥:写之间阻塞。
8.4. 锁分离
读写锁的延伸,根据不同功能拆分不同的锁,进行有效分离,栗子:LinkedBlockingQueue,takeLock和putLock操作本身是隔离的。若干个元素,一个在queue头部操作,一个在queue的尾部操作,分别持有一把独立锁。
LinkedBlockingQueue.class
9. wait & sleep
区别 (重点 -> 2)
-
来自不同的类:sleep是Thread的静态类方法,谁调用的谁去睡觉,即使在a线程里调用了b的sleep方法,实际上还是a去睡觉,要让b线程睡觉要在b的代码中调用sleep。
-
有没有释放锁(释放资源):sleep不出让系统资源;wait是进入线程等待池等待,出让系统资源,其他线程可以占用CPU。
-
一般wait不会加时间限制,因为如果wait线程的运行资源不够,再出来也没用,要等待其他线程调用notify/notifyAll唤醒等待池中的所有线程,才会进入就绪队列等待OS分配系统资源。sleep(milliseconds)可以用时间指定使它自动唤醒过来,如果时间不到只能调用interrupt()强行打断。
-
sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常。