线程安全
定义
当多线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为逗了已获得正确的结果,那这个对象就是线程安全的。
线程安全排序
不可变
不可变的对象一定是线程安全的,无论是对象的方法实现耗时方法的调用者,都不需要再进行任何的线程安全保障措施。就像Java中的String类。
绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施。
相对线程安全
需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但对一些特定顺序的连续调用,就可能需要调用端使用额外的同步手段来保证调用的正确性。如Java中的线程安全集合Vector、HashTable等
线程兼容
是指对象本身不是线程安全的,但是可以通过调用端正确地使用同步手段来保证对象在并发环境中安全的使用。如Java中的ArrayList、HashMap等。
线程对立
是指不管调用端是否采用同步措施,都无法在多线程环境中并发使用的代码。
线程安全的实现方法
互斥同步
同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只能被一条(或指定个数,使用信号量的时候)线程使用。
临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphere)都是主要的互斥实现方式。
Synchronized的底层实现
synchronized关键字经过编译之后会在同步块前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要指明一个reference类型的参数来指明要锁定和解锁的对象。如果没有明确指明,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
在执行monitorenter指令时,首先去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时将锁计数器减1,当计数器为0时,锁就被释放了。如果获取锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
ReentranLock
ReentranLock表现为API层面的互斥锁,而Synchronized表现为原生语法层面的互斥锁。ReentranLock比synchronized增加了一些高级功能;主要有等待可中断、可实现公平锁、锁可以绑定多个条件。
- 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,对处理时间非常长的同步快有帮助。
- 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁。而非公平锁则是任何一个等待锁的线程都有机会获得锁。synchronized是非公平的,ReentranLock默认也是非公平,但是可以通过制定构造函数要求使用公平锁。
- 锁绑定多个条件是指一个ReentranLock对象可以同时绑定多个Condition对象。
非同步阻塞
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也被称为阻塞同步,它属于一种悲观的并法策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题,无论共享数据是都会真的竞争,他都要进行加锁、用户态和心态转换、维护锁计数器和检查时都有被阻塞的线程需要被唤醒等操作。
乐观锁:先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生了冲突,那就再进行其他的补偿措施,最常见的措施就是不断地重试,直到成功为止,这种乐观的并发策略的许多实现都不需要线程挂起,称为非阻塞同步;
CAS算法有可能出现ABA问题,解决方法,juc包提供了一个带有标记的原子引用类AtomicStamoedReference,它可以通过控制变量值的版本来保证CAS算法的正确性。
无同步方案
-
可重入代码
可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。
可重入代码的特征:- 不依赖存储在堆上的数据和公用的系统资源;
- 用到的状态量都由参数中传入;
- 不调用非可重入的方法;
-
线程本地存储(Thread Local Storage)
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是都能保证在同一线程中执行,如果能保证,就可以吧共享数据的可见范围限制在同一个线程之内,这样就无需同步也能保证线程之间不出现数据争用问题。
synchronized的实现
图片转自:https://blog.csdn.net/zqz_zqz/article/details/70233767

- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List;
- Wait Set:那些调用wait方法被阻塞的线程被放在这里;
- OnDeck:任意时刻,最多只有一个线程在竞争锁资源,就是这里的Ondeck线程;
- Owner:当已经获取到锁资源的线程也就是正在执行同步代码块的线程;
- !Owner:当前释放的锁的线程。
执行策略
JVM每次从竞争队列尾部取出一个数据用于锁竞争候选者(Ondeck),但在并发情况下,ContentionList会大量并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。
Owner线程在unlock时将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为Ondeck线程,但并不是直接把锁给Ondeck线程,而是把竞争权利给Ondeck,Ondeck需要重新和EntryList中的线程竞争锁,JVM称之为竞争切换
Ondeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,知道某个时刻通过notify或notifyAll唤醒,则会重新进入EntryList中。
宏观实现

synchronized对象锁,其指针指向的是一个monitor对象的起始地址,每个对象实例都会有一个monitor(所以任意一个对象都可以作为锁对象)。其中monitor随着对象的创建而创建,销毁而销毁,又或者当线程视图获取对象锁时自动生成。
这里的_WaitSet就对应上面的WaitSet;_EntryList就对应上面的EntryList;_owner对应上面的owner。
关于owner
它指向持有ObjectMonitor对象的线程,当多个线程同时访问一段代码时,会先存放_EntryList集合,接下来当线程获取monitor时,就会把owner变量设置为当前线程的指针。
同步代码块的反编译结果。

monitorenter
当monitor被占用时就处于锁定状态,线程执行monitorenter指令尝试获取monitor的所有权。
- 如果minitor的进入数(_count)为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor数加1。
- 如果其他线程已经占用monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit
执行monitorexit的线程必须是monitor的所有者。指令执行时monitor的进入数减1,如果减1后进入数为0,那么线程就退出monitor,不再是monitor的所有者。其他阻塞的线程可以尝试去获取这个monitor的所有权。
同步方法的反编译结果

当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志时都被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
Synchronized的非公平性
Synchronized在线程进入ContentionList时,等待线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程不公平,还有一个不公平的就是自旋获取锁的线程还可能直接抢占Ondeck线程的锁资源。
synchronized升级过程
- 检测Mark Word 里面是不是当前线程的ID,如果是表示线程处于偏向锁;
- 如果不是,则使用CAS将线程的ID替换Mark Word,如果成功则表示当前线程获得偏向锁,置偏向标志位为1。
- 如果失败则说明存在了已经不止一个线程要获得锁资源了,撤销偏向锁,升级为轻量级锁。
- 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功则获得锁;
- 如果失败,则说明已经存在竞争了,当前线程先尝试使用自旋来获取锁;
- 如果自旋成功则依然处于轻量级状态;
- 如果自选失败,则升级为重量级锁。
Synchronized锁优化
自旋锁与自适应自旋
因为线程阻塞的实现中挂起线程和恢复线程的操作都需要转入内核态中完成,这样来回切换开销较大。而共享数据的锁定状态只会持续很短,为了这段时间将线程挂起恢复不值得。而自旋锁实现了让原本应该阻塞的线程"稍等一会"但不放弃处理器的执行时间,为了让线程等待,所以让这个线程执行一个忙循环(自旋),这个技术就是自旋锁。
自旋锁等待本身瑞然避免了线程切换的开销,但是它要占用处理器的时间,所以如果锁被占用的时间比较短的话,自旋锁效果很好,一旦锁被占用很长时间那么自选的线程只会白白消耗处理器资源。所以等待时间有一定限度,默认为自旋10次。如果超过这个阈值则自旋的线程就会挂起。
JDK1.6引入了自适应的自旋锁,自旋时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,如果对于这个锁有较高几率会在线程自旋时间内获得锁,则虚拟机将允许自旋等待持续相对更长的时间比如100次循环。而对于那些自旋很少成功获得的锁,在以后要获取这个锁时可能会省略掉自旋过程,直接挂起,避免浪费处理器资源。这样随着程序运行和性能监控不断完善,虚拟机对程序锁的状况预测会越来越准确。
锁消除
是指虚拟机即时编译器在运行时,对一些代码要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。主要判断依据是这个共享数据是否会发生逃逸,对这个共享数据进行逃逸分析。如果不会发生逃逸,那么别的线程也不会访问到这个变量,个人理解是不会发生逃逸的对象就相当于局部变量,随着方法(线程)的产生而产生,方法(线程)的销毁而销毁,可以把它们当做栈上数据对待,认为它们是线程私有的,所以就无需加锁。
逃逸分析
如果一个对象在方法体创建,并且不会被外部引用所引用到,则认为它不会发生逃逸。
例如下面这个方法里面的sb对象,它的append方法本来是synchronized修饰的同步方法,sb对象就是锁,但是在下面的方法中你虚拟机发现这个对象不会发生逃逸,因为没有外部引用能引用到它,所以就回进行锁消除,将append方法的同步锁消除。
public String concatString(String s1,String s2,String s3){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
append方法源码
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
锁粗化
原则上在编写代码的时候,将同步快的作用范围限制的越小越好,最好限制在临界区。这样即使存在锁竞争,那等待锁的线程也能尽快地拿到锁。然而如果一系列操作都对同一个对象反复加锁和解锁,甚至解锁操作出现在循环体中,那么即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能消耗。
例如:
public String concatString(String s1,String s2,String s3){
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 10000; i++) {
sb.append("String"+i);
}
}
在进行上面的循环时,将会返回地对append加锁解锁,如果虚拟机探测到有上面的情况发生则会把锁同步的范围扩展到整个操作序列外部。
例如:
public String concatString(String s1,String s2,String s3){
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 10000; i++) {
synchronized (sb){
sb.append("String"+i); //此时同步代码块里面的append方法是不加锁的
sb.append("String"+i);
sb.append("String"+i);
sb.append("String"+i);
}
}
}
轻量级锁
这里的轻量级是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为重量级锁。
使用轻量级锁的目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,而不是用来代替重量级锁的。
HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码,GC分代年龄等,这部分数据的长度在32位和64位的虚拟机总分别为32和64个bits,称为Mark Word它是实现轻量级锁和偏向锁的关键,另一部分用于存储指向方法区对象类型数据的指针,如果是数组则还会有一个额外的部分用于存储数组长度。
考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word中25Bits用来存储对象哈码,4Bits用来存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0;
| 存储内容 | 标志位 | 状态 |
|---|---|---|
| 对象哈希码、对象分代年龄 | 01 | 未锁定 |
| 指向锁记录的指针 | 00 | 轻量级锁定 |
| 指向重量级锁的指针 | 10 | 重量级锁定 |
| 空,不需要记录信息 | 11 | GC标记 |
| 偏向线程ID、偏向时间戳、对象分代年量 | 01 | 可偏向 |
轻量级锁的执行过程
在代码进入同步块是,如果此同步对象没有被锁定,虚拟机首先将在的那个前线程栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。

然后虚拟机使用CAS操作尝试将对象的Mark Word更新为指向Lock ReWord的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象mark Word的锁标志位将转变为"00"即表示轻量锁标志。

如果失败了,则虚拟机会查看对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经获得了该对象的锁,可以直接进入同步块继续执行。如果没有指向,则说明这个锁对象已经被其他线程抢占了,则锁标志的状态值变为"10"即重量级锁,Mark Word中存储的就是指向重量级锁的指针,后面等待的线程也要进入阻塞状态。
它的解锁过程也是通过CAS操作来进行的,如果对象的mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中赋值的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败则说明有其他线程尝试过获取该锁(display Mark word和原来的markword不一样了,重量级锁,所以替换失败,说明有线程也想获得这个锁,但是现在阻塞了),那就要在释放锁的同时,唤醒被挂起的线程。
如果在有竞争的情况下,轻量级锁不仅有互斥量的开销,还有发生CAS操作,因此比传统重量级锁更慢。
偏向锁
目的在于消除数据再无竞争情况下的同步原语,进一步提高程序运行性能**。偏向锁在无竞争情况下把整个同步都消除掉。**这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。如果遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
- 过程
- 访问Mark Word中的偏向锁标识是否为1,锁标志位是否为01,确认可偏向状态;
- 如果为可偏向状态,则测试对象中ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
- 如果线程ID并未指向当前线程,则通过CAS操作竞争锁(尝试将对象ID指向当前线程),如果成功,则执行5,否则说明这个锁已经被别的线程所用,执行4;
- 如果CAS获取偏向锁失败,则表示有竞争,当到达全部安全点时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续执行同步代码;撤销偏向锁时会导致stop the world
- 执行同步代码。
- 限制条件:如果程序中大多数锁都总是被不同线程访问,那么偏向锁模式就是多余的。

总结
轻量锁和偏向锁都是为了解决在无线程竞争的条件下,偏向锁是只要出现了多个线程不管是否有竞争都升级为轻量级锁。而轻量级锁是允许有多个线程访问(可以交替执行),但不能发生竞争(同一时间访问),否则升级为重量级锁。偏向锁(偏向锁状态)一旦打开,除非撤销则一直处于偏向状态,如果出现第二个线程则升级;而轻量级锁在释放锁后就会恢复为原来的状态,除非在一个线程执行过程中出现了出现竞争,才会升级。
synchronized修饰静态方法个非静态方法的区别
synchronized是对象锁,该锁针对的是该类的该实例对象,防止其他线程同时访问该类的该实例的所有synchronized块。哪个对象调用这个同步方法,就用哪个对象锁。但是如果调用同步块的对象是两个对象,则就可以同时访问。static synchronized是类锁,又称全局锁。该锁针对的是类,无论实例化多少个对象,都只能排队调用该锁的同步块。使用该类的class对象来加锁,class对象只有一个,而实例出的对象可以有多个。

本文详细介绍了线程安全的概念、实现方法及其在Java中的具体应用。包括不可变对象、绝对线程安全、相对线程安全和线程对立的概念。讨论了互斥同步、乐观锁和无同步方案等实现方式。

2757

被折叠的 条评论
为什么被折叠?



