非线程安全问题是指多个线程对同一个对象中的同一个变量进行读写操作时出现的值被更改或者值不同步的情况。
public class TestThreadSafe {
private static int count;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread("t1"){
@Override
public void run() {
for(int i=0;i<5000;i++){
count++;
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
for(int i=0;i<5000;i++){
count--;
}
}
};
t1.start();
t2.start();
//让主线程同步等待t1、t2线程的结果的返回,注意join()不能解决线程安全问题,因为t1、t2线程
t1.join();
t2.join();
log.debug("count:{}",count);//结果可能为正数、0、负数
}
}
什么原因导致了线程安全的问题?
//i++对应字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
//i--对应字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
如果是单线程,那么以上8条指令会按照顺序同步执行,不会出现指令交错执行的问题:
但是对于多线程,以上8条指令可能会出现指令交错执行的问题,出现负数的情况:
出现正数的情况:
总结:一个程序运行多个线程是没有问题的,问题出在多个线程访问了共享资源,多个线程访问共享资源其实也没有问题,问题在于多个线程在对共享资源进行读写操作时发生指令交错就会出现线程安全问题。
一段代码块中如果出现对共享资源的多线程读写操作,这段代码就称为临界区。
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
synchronized俗称对象锁,它采用互斥的方式让同一时刻至多只有一个线程能够持有对象锁,其他线程再想获取这个对象锁就会阻塞住,这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切
换所打断 。
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码。同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
public class TestThreadSafe {
private static int count;
static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread("t1"){
@Override
public void run() {
for(int i=0;i<5000;i++){
//对临界区的代码加上synchronized
//当一个线程想要执行临界区的代码时需要先获得对象锁,如果其他线程已经获得了该对象锁,那么当前线程就会被阻塞。
synchronized (object){
count++;
}
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
for(int i=0;i<5000;i++){
//对临界区的代码加上synchronized
synchronized (object){
count--;
}
}
}
};
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count:{}",count);
}
}
Java中的每一个对象都可以作为锁。具体表现为以下3种形式:
❑ 对于普通同步方法,锁是当前实例对象。
public class SynchronizedDemo {
public synchronized void methodOne() {
}
}
❑ 对于静态同步方法,锁是当前类的Class对象。
public class SynchronizedDemo {
public static synchronized void methodOne() {
}
}
❑ 对于同步方法块,锁是synchonized括号里配置的对象。
public class SynchronizedDemo {
public void methodThree() {
// 对当前实例对象this加锁
synchronized (this) {
}
}
public void methodFour() {
// 对class对象加锁
synchronized (SynchronizedDemo.class) {
}
}
}
synchronized不论是修饰代码块还是修饰方法都是通过持有对象锁来实现同步的。而这个对象的markword就指向了一个Monitor(锁/监视器)
//obj对象锁
synchronized (obj){
//临界区代码
}
1、java对象头的markword结构:
我们都知道对象是放在堆内存中的,对象大致可以分为三个部分,分别是对象头,实例变量和填充字节,对象头分成两个部分:mark word和 klass word
锁的类型和状态在对象头Mark Word中都有记录。在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。对于重量级锁对象的markword包含两个部分:指向重量级锁的指针和标志位
由此看来,monitor锁对象地址存在于每个Java对象的对象头中
2、Monitor结构:
每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)
//部分属性
ObjectMonitor() {
_count = 0; //锁计数器
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
}
3、synchronized底层原理 = java对象头markword + 操作系统对象monitor:
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
- synchronized无论是加在同步代码块还是方法上,效果都是加在对象上,其原理都是对一个对象上锁
- 如何给这个obj上锁呢?当一个线程Thread-1要执行临界区的代码时,首先会通过obj对象的markword指向一个monitor锁对象
- 当Thread-1线程持有monitor对象后,就会把monitor中的owner变量设置为当前线程Thread-1,同时计数器count+1表示当前对象锁被一个线程获取。
- 当另一个线程Thread-2想要执行临界区的代码时,要判断monitor对象的属性Owner是否为null,如果为null,Thread-2线程就持有了对象锁可以执行临界区的代码,如果不为null,Thread-2线程就会放入monitor的EntryList阻塞队列中,处于阻塞状态Blocked。
- 当Thread-0将临界区的代码执行完毕,将释放monitor(锁)并将owner变量置为null,同时计算器count-1,并通知EntryList阻塞队列中的线程,唤醒里面的线程
public class TestSynchronized {
static final Object obj = new Object();
static int i=0;
public static void main(String[] args) {
synchronized (obj){
i++;
}
}
}
对应的字节码为:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // 获取obj对象
3: dup
4: astore_1
5: monitorenter //将obj对象的markword置为monitor指针
6: getstatic #3
9: iconst_1
10: iadd
11: putstatic #3
14: aload_1
15: monitorexit //同步代码块正常执行时,将obj对象的markword重置,唤醒EntryList
16: goto 24
19: astore_2
20: aload_1
21: monitorexit //同步代码块出现异常时,将obj对象的markword重置,唤醒EntryList
22: aload_2
23: athrow
24: return
Exception table:
from to target type
6 16 19 any //监测6-16行jvm指令,如果出现异常就会到第19行
19 22 19 any
于这两条指令的作用,我们直接参考JVM规范中描述:
monitorenter 指令:
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit指令:
执行monitorexit的线程必须是持有obj锁对象的线程
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程释放monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出IllegalMonitorStateException的异常的原因。
public class TestSynchronized {
static int i=0;
public synchronized void add(){
i++;
}
}
对应的字节码指令:
public synchronized void add();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field i:I
3: iconst_1
4: iadd
5: putstatic #2 // Field i:I
8: return
从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
Java SE1.6中为了减少获得锁和释放锁带来的性能消耗而引入自旋锁**、适应性自旋锁、**锁消除、锁粗化、偏向锁、轻量级锁等技术。锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即语法仍然是 synchronized
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
创建锁记录(Lock Record)对象,每个线程的栈帧中都会存放一个锁记录的结构,这个锁记录内部存储的是obj对象的markword。
让锁记录中的Object reference指向锁对象,并尝试使用CAS(compare and swap) 替换obj的markword,将markword的值存入锁记录中:
3. 如果CAS交换成功,对象头中就存放了Thread-0栈帧中的锁记录地址和状态00,表示这时由该线程对对象加锁,如图:
如果CAS失败,有两种情况:
如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
当Thread-1对对象obj加轻量级锁时,Thread-0已经对该对象加了轻量级锁
这是Thread-1加轻量级锁失败,进入锁膨胀流程,即为obj对象申请Monitor锁,让obj指向重量级锁地址,然后自己进入monitor的EntryList阻塞:
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步
块,释放了锁),这时当前线程就可以避免阻塞。
如果Thread-1
在尝试加轻量级锁的过程中,CAS 操作失败,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时可以使用自旋锁来优化等待其他线程释放锁,自旋锁就是让当前线程循环不断的CAS。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了其他还没有释放锁,或者·Thread-1
还在执行,Thread-2
还在自旋等待,这时又有一个线程Thread-3
过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
优点:开启自旋锁后能减少线程的阻塞,在对于锁的竞争不激烈且占用锁时间很短的代码块来说,能提升很大的性能,在这种情况下自旋的消耗小于线程阻塞挂起的消耗。
缺点:在线程竞争锁激烈,或持有锁的线程需要长时间执行同步代码块的情况下,使用自旋会使得cpu做的无用功太多。
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会
高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java 7 之后不能控制是否开启自旋功能
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:
- 当Thread-0第一次指向临界区的代码时,会使用CAS将线程ID(ThreadID)设置到obj对象的Markword中
- 当Thread-0线程再次获取锁时,先比较java对象头markword中ThreadID和当前线程的ThreadID是否一致,如果一致,表示没有竞争,不用重新进行CAS操作,以后只要不发生竞争,这个U对象就归该线程所有。
- 如果对象头Markword中的ThreadID和当前线程的ThreadID不一致,说明存在竞争,就会撤销偏向锁,升级为重量级锁。
static final Object obj = new Object();
public static void m1() {
synchronized( obj ) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized( obj ) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized( obj ) {
偏向状态
// 同步块 C
}
}
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作,降低了性能:
如果改成偏向锁:
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。
保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。
我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
主要是锁不同:
修饰方法时,对于静态方法,是把 class 作为锁;对于非静态方法,是把 this 对象当做锁;
修饰代码块时,是把任何对象作为锁,如果锁对象为空,会抛出 NullPointerException,但是修饰方法不会;
在锁的作用区域上,修饰方法时是整个方法体;而修饰代码块时只有对应的代码块。后者更加灵活和细粒度。
可以把修饰方法看作是修饰代码块的一种特殊形式,一种快捷方式。
可重入,具体实现:
static final Object obj = new Object();
public static void m1() {
synchronized( obj ) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized( obj ) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized( obj ) {
偏向状态
// 同步块 C
}
}