Java开发之高并发必备篇——线程安全操作之synchronized

Posted weixin_43802541

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java开发之高并发必备篇——线程安全操作之synchronized相关的知识,希望对你有一定的参考价值。

提到并发编程大多数第一时刻想到的就是synchronized同步锁了,synchronized也是面试中问的比较多的一个问题。在之前的文章中我们提到过线程安全的三个特性:原子性、可见性和有序性,并且说到了java中定义了一个关键字synchronized对于线程的这三个特性都实现了,这么说来synchronized关键字是可以保证线程安全的,那么如何使用synchronized来实现线程安全?它又是怎么样的一个实现原理呢?这篇文章我们将从下面的几个内容来聊聊synchronized这个关键字。

1.synchronized介绍

synchronized是java中的一个关键字,翻译成汉语是同步的意思,它的作用呢就是被其修饰的方法或者代码块在同一时刻只能有一个线程进行访问和执行,只有当该线程执行完毕之后其他的线程才可以进行竞争访问,并且当前访问的线程可以重复的申请竞争访问资源。

可以想象为大家都在一个窗口买东西,同一时刻只能有一个人在买,其他人都在等待,不一样的是外面等待的人并不会排好队而是都在等着正在买东西的人买完之后争夺到买东西的机会,并且现在买的人买完之后还能申请继续争夺买东西的机会。

简单来说:synchronized可以修饰代码块和方法,它可以保证同一时刻只能有一个线程访问被修饰的代码块或者方法,从而保证了线程的安全。synchronized犹如一把锁,当一个线程访问之后就锁定访问的共享资源代码段,达到互斥的效果,从而保证了线程的安全,并且同一个线程可以获取同一把锁多次,达到可重入的效果。

· synchronized特性

synchronized具有原子性、可见性、有序性和可重入性;

原子性

之前文章说到过,JVM中定义的8种原子性操作中的lock和unlock就是把非原子性操作变成原子性操作,例如a+=1为非原子性操作。JVM中使用的两个字节码指令monitorenter和monitorexit来实现lock和unlock操作,但是在java代码中没有直接操作JVM指令的,而是把这两个指令都封装到了synchronized关键字中来实现原子性操作。(讲解synchronized原理的时候会看到monitorenter和monitorexit指令封装)

可见性

synchronized保证有序性是因为unlock解锁操作之前必须把工作内存中数据同步回主内存来实现的;而主内存是所有线程都可以访问的共享内存,所以修改之后其它线程操作该数据时都可以看到被修改后的值。

有序性

synchronized实现有序性是因为当一个共享资源变量被lock锁定操作之后,同一时刻只能被一个线程使用,而单线程执行代码是没有指令重排等问题的,所以线程也是有序的。同样被lock锁定的共享资源排斥其他线程访问所以Synchronized也具有互斥性。

可重入性

synchronized的可重入性就是当一个线程调用synchronized代码持有对象锁的时候,如果调用了该对象的其他synchronized代码,那么可以重新持有该锁,即同一个线程可以获取同一把锁多次,所以synchronized具有可重入性。

2.synchronized的使用

synchronized同步锁主要分两种,一种是对象锁,另外一种是类锁;

· 对象锁

对象锁顾名思义锁的作用对象是实例对象,当synchronized修饰普通的方法或者代码块的时候,都可以指定锁的对象。因一个类可以有很多对象,所以对象锁是可以有多个的。

修饰普通方法:被称为同步方法,其锁作用范围是这个普通方法的所有代码,作用的对象是调用这个普通方法的对象。

修饰代码块:被称为同步代码块,其锁作用范围是这个代码块的所有代码,作用的对象是调用这个代码块的对象。

· 类锁

每个类都只有一个对应的Class对象(反射对象),类锁其作用的对象就是类的Class对象了,或者当锁的对象为一个静态对象的时候也是类锁。当synchronized修饰静态方法或者代码块的时候都可以使用类锁。

修饰静态方法/代码块:其锁的作用范围为定义的静态方法或代码块的代码。作用的对象就是在调用该静态方法或代码块的所有对象。

下面我们还是以之前卖票的MyRunnable为例看看对象锁和类锁的使用。

对象锁的使用代码如下:

运行结果如图:

分析:通过运行我们发现,当使用了同步代码块或者同步方法的对象锁方法实现,线程就是同步执行的了。

值得注意的是对象锁中同步普通的方法锁的对象是this即锁的作用对象是当前的MyRunnable对象,而对象锁的同步代码块锁的对象可以是this也可以是Object类型。

synchronized(this)和synchronized(obj)的区别:

synchronized(this)所的作用对象是当前访问的对象,而synchronized(obj)的作用对象是obj,如果多个线程共用一个obj对象那么执行的时候还是同步执行的,如果每个线程obj锁的对象不同那么还是异步执行。如下代码就是异步执行:

另外同步代码块的方式因为可以控制锁的代码范围即控制锁的粒度,所以有些场合下使用同步代码块的效率要更高。

类锁的使用代码如下:

因为锁的是class对象或者静态对象,所以我们测试时候可以每个线程创建不一样的MyRunable2对象来进行测试,代码如下:

运行结果如下:

分析:通过测试我们发现类锁对于锁定的类class的所有对象都成立。

3.synchronized原理

为了研究synchronized的原理,我们就需要对使用这个关键字的java文件编译之后生成的class文件进行反编译,查看下java字节码对应的机器指令是怎么样的。

Java代码是这样的:

通过jdk自带的javap工具对SyncTest.class文件进行反编译获取字节码指令,执行命令“javap-v SyncTest”,然后获取到反编译的结果如图所示:

同步代码块

同步方法

我们可以看到使用同步代码块的test方法中看到两个熟悉的指令monitorenter、monitorexit,即遇到synchronized的时候执行monitorenter指令获取到锁,而当方法运行结束时执行monitorexit指令释放锁。其他指令有兴趣的话可以百度“JVM虚拟机字节码指令表”查看具体含义。

monitorenter和monitorexit通过官方介绍的这两个指令进行翻译之后的大体上是这样的:

“对象都有一个监视器锁(monitor)关联,当且仅当拥有所有者时,monitor才会被锁定,并且会有一个计数器记录着锁的次数,如果未获取到monitor锁那么计数为0。执行到monitorenter指令的线程,会尝试去获得对应的monitor锁,如果获取成功则计数加1,当同一个线程再次获得该对象的锁的时候,计数器再次+1,当其他线程想获得该monitor的时候,就会阻塞,直到计数器为0才能成功。线程执行monitorexit指令,就会让monitor的计数器-1。如果计数器为0,表明该线程不再拥有monitor锁。其他线程就允许尝试去获得该monitor锁”。

monitorenter和monitorexit的执行流程图如下:

而在同步方法test2的反编译字节码中并没有看到monitorenter和monitorexit两个指令,但是发现图中红色框中标记了一个flags值为ACC_SYNCHRONIZED。ACC_SYNCHRONIZED介绍如下:

“方法级同步是隐式执行的,作为方法调用和返回的一部分。同步方法在运行时常量池的methodinfo结构中通过ACCSYNCHRONIZED标志进行区分,该标志由方法调用指令检查。当调用为其设置了ACC_SYNCHRONIZED的方法时,执行线程进入monitor监视器,调用方法本身,然后退出监视器,不管方法调用是正常完成还是突然完成。在执行线程拥有监视器期间,其他线程不能进入监视器。如果在调用synchronized方法的过程中抛出异常,并且synchronized方法不处理该异常,则在将异常从synchronized方法中重新抛出之前,该方法的监视器将自动退出”。

通过上面的描述我们知道了同步方法通过标志值为ACC_SYNCHRONIZED也可以获取到monitor锁,并在方法结束的时候会释放monitor锁,从而也达到了同步的效果。

4.JDK1.6对synchronized的优化

上述介绍了synchronized的使用和原理,我们发现虽然synchronized锁实现了并发安全,但是它有点“重”,因为当一个线程访问同步方法或者代码块获取锁了之后,其他的线程都处于等待阻塞状态,浪费CPU的资源,并且频繁的获取和释放锁也消耗CPU的性能等等,所以以前一提到synchronized大家都说它是一个重量级锁。但是到JDK1.6的时候就对synchronized进行了各种优化来提高它的效率,如JVM会对java代码进行锁粗化、锁消除处理,适应性自旋解决自旋占用大量CPU资源问题,并且加入了偏向锁和轻量级锁等对锁进行了升级优化,最后才是重量级锁。

· 锁粗化

加锁的共享资源范围越小,那么其他线程等待阻塞的时间就会越短,这样明显比对大范围资源加锁效率高。但是加锁和释放锁也需要时间和消耗资源的,如果出现频繁的加锁和释放锁操作那么就会导致消耗CPU性能,锁粗化就是解决这种问题的。锁粗化就是在出现很小范围内代码进行连续加锁释放锁操作的时候,对其锁的范围进行扩大,这样锁就变成了外部的一个,避免了小范围频繁的锁操作。典型的案例就是for循环,如下:

· 锁消除

锁消除是指当java进行JIT(Just-In-Time)编译(即时编译:程序运行时把Class文件字节码编译成本地机器码来提高执行效率)运行程序的时候,通过上下文进行逃逸分析(逃逸分析:如果变量被方法中使用,又被方法外使用,那么这个变量就发生了逃逸)发现如果变量发生了逃逸那么应该保持锁,如果没有发生逃逸那么不存在竞争资源的问题从而会把锁消除掉,案例如下:

我们知道StringBuffer是一个线程安全的类,它的append方法被synchronized修饰,但是此处因为sb变量只是一个局部变量,sb 的所有引用不会 “逃逸” 到 test方法之外其他线程无法访问控制到它,所以即使append方法操作有锁,JVM即使编译后就会把这个锁消除掉,上述代码就会忽略掉同步锁而执行。

· 悲观锁、乐观锁、CAS的概念

悲观锁:在使用synchronized的时候,如果一个线程获取到锁,那么它就非常的悲观,认为其他线程访问共享资源会出现冲突,所以其他线程会被阻塞。

CAS操作:compare and swap意思是比较并交换,CAS操作中有三个参数:内存位置(V)、预期原值(A)和新值(B);如果内存位置的值与预期原值相匹配,那么会自动将该值更新为新值 ,如果不一样那么重新计算直到一直为止。

乐观锁:CAS的操作就属于乐观锁,不加锁,而是认为多线程访问共享资源不会出现冲突的情况,如果出现了冲突那么就重试,直到内存值和预期值不冲突为止。

· 锁升级

JDK1.6后synchronized的锁状态总共有4种:无锁—>偏向锁—>轻量级锁—>重量级锁,锁的升级顺序是从无锁到重量级的顺序,锁只能升级不能降级。

在说锁的升级原理之前呢,我们先了解下我们的对象,大部分对象都是存储在堆中,而对象的组成主要有三部分:对象头、实例数据、对齐填充;

对象头

对象头由MarkWord 、指向类的指针、以及数组长度三部分组成,这里我们需要着重熟悉的就是MarkWord部分。MarkWord 用于存储对象的运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。MarkWord 的内容变化会随着锁的升级而变化。具体变化如下表:

实例数据

对象真正存储的有效信息,也就是代码中定义的各种字段内容。

对齐填充

对齐填充没有特别的含义不是必然存在的,它仅仅起着占位符的作用。

无锁

当对象已创建存储在内存中,它对象头MarkWord锁标志默认就是无锁状态,无锁状态不存在资源的锁定。

偏向锁

很多时候可能一段同步代码总是被一个线程多次访问,这时候并不存在多线程竞争的问题,这时候就是加入偏向锁,使得该线程在后续访问中自动获取到锁,降低了频繁获取锁释放锁代码的资源消耗。

原理是当一个线程执行同步方法或者代码块的时候,首先从对象头中的MarkWord中获取是否是偏向锁标志:

(1)如果标志为0证明当前为无锁状态,就会将当前线程的ID添加到对象头的MarkWord中,然后将是否是偏向锁标志改为1,再执行同步代码;

(2)如果标志为1证明已经是偏向锁状态,那就从MarkWord中获取到偏向线程ID跟当前线程ID比较,如果一样则不需要再次获取锁直接执行同步代码;如果不一样执行CAS操作将MarkWord的线程ID设置为当前线程ID,设置成功则执行同步代码,如果CAS操作失败证明存在多线程竞争情况,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁。

轻量级锁

轻量级锁是指当前线程是偏向锁但是被其他线程访问的时候则升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

自旋:线程的阻塞和唤醒需要C P U从用户态和内核态进行转换,比较耗时,所以JVM在发现锁被一个线程占用的时候,并不会让其他线程阻塞而是一直循环检测锁是否被释放,当然自旋的次数有限制(可以通过JVM参数-XX:PreBlockSpin 修改),如果达到次数还是没有获取锁才会被挂起。

升级为轻量级锁主要有两种情况,第一种就是我们说的当前线程的偏向锁被其他线程访问的时候会把当前线程升级为轻量级锁;另外一种就是关闭了偏向锁功能(JVM参数 -XX:-UseBiasedLocking )。

如果当前线程获取到的是轻量级锁,锁标志为00,如果还有一个线程访问的时候就会进行自旋,但是如果自旋超过了设定的自旋次数,这个线程还是会阻塞,或者在线程自旋的过程中又有其他线程访问了那么就会把轻量级锁升级为重量级锁。

重量级锁

当轻量级锁升级为重量级锁之后,锁的标志改为10,就会变成我们最初所说的现象,一个线程访问其他线程都阻塞,并且重量级锁底层依赖的是操作系统的互斥锁(Mutex Lock)实现的,线程的切换需要用户态和内核态的转换,比较耗时效率低。

最后我们使用张流程图简单的来总结下锁的升级过程:

以上是关于Java开发之高并发必备篇——线程安全操作之synchronized的主要内容,如果未能解决你的问题,请参考以下文章

Java开发之高并发必备篇——线程的状态调度和操作方法

Java开发之高并发必备篇——线程的状态调度和操作方法

Java开发之高并发必备篇——线程为什么会不安全?

Java开发之高并发必备篇——线程为什么会不安全?

Java开发之高并发必备篇——线程池

Java开发之高并发必备篇——Lock和ReentrantLock