【Java并发】- 2.对Synchronized关键字的深入解析
文章目录
1.概念
是利用锁的机制来实现同步的。
锁机制有如下两种特性:
-
互斥性:**即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性。
-
可见性:**必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。
2.synchronized的用法
根据修饰对象分类
1、同步方法
(1) 同步非静态方法
Public synchronized void methodName(){
}
(2) 同步静态方法
Public synchronized static void methodName(){
}
2、同步代码块
synchronized(this|object) {}
synchronized(类.class) {}
如下:
Private final Object MUTEX =new Object();
Public void methodName(){
Synchronized(MUTEX ){
}
}
根据获取的锁分类
1、获取对象锁
synchronized(this|object) {}
修饰非静态方法
在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。
2、获取类锁
synchronized(类.class) {}
修饰静态方法
在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。
在上面对synchronized进行简单了解后,我们下面再对synchronized关键字进行深入了解
3.通过字节码来查看synchronized关键字的具体实现方式
修饰代码块
示例代码:
public class MyTest1 {
private Object object = new Object();
public void method(){
synchronized (object){
System.out.println("hello word");
}
}
public void method2(){
synchronized (object){
System.out.println("hello word");
throw new RuntimeException();
}
}
}
我们先来看看method方法的字节码:
public void method();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: getfield #3 // Field object:Ljava/lang/Object;
4: dup
5: astore_1
6: monitorenter
7: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
10: ldc #5 // String hello word
12: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
15: aload_1
16: monitorexit
17: goto 25
20: astore_2
21: aload_1
22: monitorexit
23: aload_2
24: athrow
25: return
一个小知识点:
args_size=1–为什么method这个非静态方法为无参,但是其参数大小为1
因为要把类中的this传入,故Java会默认非静态无参方法的参数为1,与python中第一个把this传入类似
通过对字节码的分析可以得出以下结论:
synchronized关键字修饰代码块时,是通过monitorenter和monitorexit两个关键字来实现加锁的工作。
此时有一个问题:为什么在method中monitorexit关键字会出现两次?
因为程序会有两种退出锁的情况,
- 1.是正常执行完代码,然后通过monitorexit释放锁对象。
- 2.synchronized关键字修饰的代码有异常,会异常退出。此时代码会直接越过第一个monitorexit的作为范围,就会造成程序退出了也没有释放锁对象。造成并发问题。所以要在条件一个monitorexit来让程序在发生异常时也能释放锁对象。
那么有没有可能让字节码只执行一次monitorexit。有那就是把上面的两种情况进行统一。就是在程序正常执行时也抛出一个异常,那么无论在抛出异常的代码之前的代码正常还是异常,程序最后都会走异常处理的流程,就不需要正常的monitorexit处理,这样字节码中就会只有一个monitorexit
我们来看看method2方法的字节码:
public void method2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: getfield #3 // Field object:Ljava/lang/Object;
4: dup
5: astore_1
6: monitorenter
7: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
10: ldc #5 // String hello word
12: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
15: new #7 // class java/lang/RuntimeException
18: dup
19: invokespecial #8 // Method java/lang/RuntimeException."<init>":()V
22: athrow
23: astore_2
24: aload_1
25: monitorexit
26: aload_2
27: athrow
可以看到只有一个monitorexit来释放锁对象。
修饰非静态方法
示例代码:
public class MyTest2 {
public synchronized void method(){
System.out.println("hello");
}
}
来看看method的字节码:
public synchronized void method();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
通过字节码可知,当synchronized关键字修饰方法时被没有采用monitorenter和monitorexit两个关键字来进行加锁。而是通过ACC_SYNCHRONIZED标志
JVM通过ACC_SYNCHRONIZED访问标志来区分一个对象是否为同步方法;当方法被调用时调用指令回去检查该方法是否拥有ACC_SYNCHRONIZED标志,如果有线程就会先持有方法所在对象的Monitor对象然后执行方法本体,在方法执行期间其他方法都不能获取该monitor对象。当线程执行完该方法后会释放这个monitor对象。
修饰静态方法
示例代码:
public class MyTest3 {
public static void main(String[] args) {
}
public static synchronized void method(){
System.out.println("hello");
}
}
其中method方法的字节码为:
public static synchronized void method();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
可以知道synchronized关键字修饰静态方法和修饰非静态方法时类似,不同的是在修饰静态方法时多出了一个 ACC_STATIC标识,在获取monitor对象时,如果有ACC_STATIC标志那么线程就不会获取对象的monitor对象,而是获取静态方法所在类的class对象。
4.synchronized底层中自旋存在的意义
JVM的同步是基于进入和退出监视器对象(管程对象)(monitor)来实现。每个对象实例都会有一个Monitor对象,Monitor对象回和Java对象一同创建并销毁,Monitor对象是由C++实现。
当多线程同时访问一段代码时,这些线程会被放在一个EntryList集合中,处于阻塞状态的线程都会被放在这个列表中。接下来当线程获取到对象的Monitor时,Monitor是依赖底层操作系统的mutex lock来实现互斥的,线程获取mutex成功就会持有该mutex,这时其他线程就无法获取该mutex。
如果线程调用了wait方法那么该线程就会释放掉所持有的mutex中,而且该线程会进入到waitset集合中,等待下一次被其他线程的notify/notifyall唤醒。如果线程顺利执行完毕,那么它也会释放掉mutex。
总结就是:同步锁的实现中,因为monitor是由底层的操作系统实现的,这样就存在用户态和内核态之间的切换,会增加性能的开销。
那些处于EntryList和WaitSet中的线程均处于阻塞状态,阻塞操作是由操作系统完成的,在linux中是通过pthread_mutex_lock函数实现,线程被阻塞后会进入内核调度状态,这会导致系统在内核态和用户态之间来回切换,严重影响系统的性能。
解决上述状态切换问题的方法就是自旋(spin)。其原理是:当线程发生Monitor争用时,如Owner能够在短时间内释放掉锁,那么这些争用的线程就可以稍微等一下(就是自旋一段时间),在Owner线程释放掉锁之后,争用线程可能立即就获取到锁,从而避免了系统阻塞。不过如果当Owner运行的时间超过了临界值 后,争用线程自旋一段时间后依然无法获取到锁,这时争用线程则会停止自旋而进入到阻塞状态。所以总体的思想是:先自旋,不成功再进行阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说有极大的性能提升。显然,自旋在多处理器(多核心)上才有意义。
通过openJDK底层源码来分析ObjectMonitor底层实现
该源码位于hotspot-master\src\share\vm\runtime目录下objectMonitor.cpp和objectMonitor.hpp两个文件,我们先来分析objectMonitor.hpp文件
class ObjectWaiter : public StackObj {
public:
enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
enum Sorted { PREPEND, APPEND, SORTED } ;
ObjectWaiter * volatile _next;
ObjectWaiter * volatile _prev;
Thread* _thread;//表示线程对象
ParkEvent * _event;
volatile int _notified ;
volatile TStates TState ;
Sorted _Sorted ; // List placement disposition
bool _active ; // Contention monitoring is enabled
public:
ObjectWaiter(Thread* thread);
void wait_reenter_begin(ObjectMonitor *mon);
void wait_reenter_end(ObjectMonitor *mon);
};
见名字ObjectWaiter 我们就可看出这是对阻塞在对象monitor上的线程的一个封装,Thread * 表明了线程,而volatile _next和volatile _prev则可以看出是一个链表的实现。
以上是jvm底层对等待集合中线程的封装,接下来我们看看之前文章中一直提到的monitor的具体实现。
class ObjectMonitor {
public:
enum {
OM_OK, // no error
OM_SYSTEM_ERROR, // operating system error
OM_ILLEGAL_MONITOR_STATE, // IllegalMonitorStateException
OM_INTERRUPTED, // Thread.interrupt()
OM_TIMED_OUT // Object.wait() timed out
};
.......(为了文章的简洁我省略了部分的具体实现)
// initialize the monitor, exception the semaphore;信号量, all other fields
// are simple integers or pointers
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
...
}
以上的ObjectMonitor就是我文中一直提到的monitor。
可以看到其enum中定义了几种状态,而Java中相关异常就是通过其来实现的。如超时,系统错误等。
在ObjectMonitor对象的的初始化函数中几个重要的属性
- _WaitSet 这就是Java中线程执行wait方法后线程被存放的位置。
private:
protected:
ObjectWaiter * volatile _WaitSet; // LL of threads wait()ing on the monitor
//这是WaitSet的类型,就是上述的ObjectWaiter
- _EntryList 是线程发生争用后未竞争到monitor的被阻塞的线程存放的位置
protected:
ObjectWaiter * volatile _EntryList ; // Threads blocked on entry or reentry.
//EntryList也是ObjectWaiter,这就证明了我上述说过处于EntryList和WaitSet中的线程均处于阻塞状态
- _owner:Owner表示的是获取到monitor线程后正在执行的线程
protected: // protected for jvmtiRawMonitor
void * volatile _owner; // pointer to owning thread OR BasicLock
- _recursions:同PTHREAD_MUTEX_RECURSIVE_NP表示同一线程对同一对象多次加锁的次数,加一次锁_recursions加一,解一次锁减一。
protected: // protected for jvmtiRawMonitor
void * volatile _owner; // pointer to owning thread OR BasicLock
volatile intptr_t _recursions; // recursion count, 0 for first entry
//这是_recursions的定义
- _WaitSetLock:这是线程自旋的标识
private:
volatile int _WaitSetLock; // protects Wait Queue - simple spinlock
互斥锁
通过对象互斥锁的概念来保证共享数据操作的完整性。每个对象都有一个被称为“互斥锁”的标记,这个标记保证在如何时刻只有一个线程访问该对象。
互斥锁的属性:
- PTHREAD_MUTEX_TIMED_NP:这是缺省值,也就是普通锁。当一个线程加锁以后,其余请求锁的线程将会形成一个等待队列,并且在解锁后按照优先级获取到锁。这种策略可以确保资源分配的公平性。
- PTHREAD_MUTEX_RECURSIVE_NP:嵌套锁。允许一个线程对同一个锁成功获取多次,并通过unlock解锁。如果是不同线程请求,则在加锁线程解锁时重新进行竞争。
- PTHREAD_MUTEX_ERRORCHECK_NP:检错锁。如果一个线程请求同一个锁,则返回EDEADLK,否则与PTHREAD_MUTEX_TIMED_NP类型动作相同,这样就保证了当不允许多次加锁时不会出现最简单情况下的死锁。
- PTHREAD_MUTEX_ADAPTIVE_NP:适应锁,动作最简单的锁类型,仅仅等待解锁后重新竞争。
5.synchronized的锁升级
在JDK5以前如果要使用线程同步,只能通过synchronized关键字这一种方式来实现,底层Java保证数据的原子性也是通过synchronized关键字来维护的,synchronized是JVM的一种内置锁,这种锁的获取和释放都是由JVM隐式来实现的。
从JDK5开始并发包引入了Lock锁,Lock同步锁是基于Java实现的,因此锁的获取和释放都是通过Java代码实现。然而synchronized是基于底层操作系统的metux Lock实现,每次对锁的获取和释放动作都会导致用户态和内核态的切换,而这种切换会极大的影响性能。在并发较高时,即锁的竞争比较激烈时,synchronized表现的就非常差。
故从JDK6开始Java对synchronized的底层实现就发生了很大的变化,JVM引入相应的优化手段来提升synchronized的性能,这种提示就涉及偏向锁、轻量级锁和重量级锁等。从而减少了锁竞争带来的用户态和内核态的切换;这些锁的优化是通过对象头中的一些标志位来实现的,对锁的访问实际上都和对象头息息相关。
从JDK6开始Java对象在堆中会被分为三部分:对象头、实例数据和对其填充。
对象头
对象头也是由三部分组成:
- Mark Word
- 指向类的指针
- 数组长度
其中Mark Word(它记录了对象、锁及垃圾回收相关的信息。在64位系统中其长度位64bit)的位信息包括以下信息: - 1.无锁标记
- 2.偏向锁锁标记
- 3.轻量级锁标记
- 4.重量级锁标记
- 5.GC标记
synchronized中锁的演化
对于synchronized锁来说,锁的升级是通过Mark word中锁标志位与是否是偏向锁标志位来达成。Synchronizeed关键字对应的锁都是从无锁到偏向锁开始,随着线程竞争的不断升级,逐渐演化到轻量级锁,最后变成重量级锁。
锁的演化过程:无锁->偏向锁->轻量级锁->重量级锁
偏向锁
针对一个线程来说偏向锁就是优化同一个线程多次获取一个锁的情况。如果一个synchronized修饰的方法被一个线程访问,那么该方法所在的对象就会在Mark word中进行标记同时还会有一个字段来存储该线程的id;当线程访问方法结束后,再次访问同一个synchronized方法时,它会检查这个对象Mark word中的偏向锁标记及是否指向了这个线程的id。如果是的话,该线程无需进入管程(Monitor)而是直接执行该方法。
如果在此过程中有其他的线程来访问这个synchronized方法偏向锁就会被取消,从而升级位轻量级锁。
轻量级锁
如果一个线程已经获取了一个对象的锁,此时有其他线程也来获取这个对象的锁,由于该对象的锁已经被获取了因此它是偏向锁,而此时第二个线程开始竞争,会发现对象的Mark word已经标记位偏向锁,但是存储的ID不是它自己,(是第一个线程的)那么他就会进行CAS(compare and swap)来等待直到获取锁。
在这个过程中会有两种情况
- 1.获取锁成功:就会将对象Mark Word中的线程ID更改为它自己,对象的锁保持偏向锁状态
- 2.获取锁失败:则表示此时有多个线程共同竞争这个锁,那么此时对象就会把锁进行升级,从偏向锁升级位轻量级锁。
自旋锁
是轻量级锁的一种实现方式。
如果自旋锁自旋失败(线程无法获取到锁),那么锁就会转化位重量级锁。无法获取锁的线程会进入Monitor的EntryList中。
自旋锁的特点是避免了线程从用户态进入内核态。
重量级锁
线程最终从用户态进入内核态。
6.Java对synchronized进行的优化
逃逸分析技术
JIT编译器(just in time)在动态同步编译代码时,会使用一种逃逸分析的技术。该技术会判断线程中所使用的锁对象是否只是被 一个线程使用,而没有散布到其他线程中。如果是这种情况,那么JIT编译器在编译这个同步代码时就不会生成synchronized关键字 标识的锁申请和释放的机器码。从而消除了锁的使用。
示例代码:
public class MyTest4 {
public void method(){
Object object = new Object();
synchronized (object){
System.out.println("hello");
}
}
}
object对象只在方法内部的一个同步代码块中使用,符合上述的情况。故在编译时就会省去锁申请和释放的机器码。
锁粗化技术
JIT编译器(just in time)在动态同步编译代码时,若发现前后相邻的synchronized块使用的是同一个锁对象。那么它就会把这几个synchronized 代码块合并为一个较大的同步块,这样做的好处是在执行这些代码时就不会频繁的释放和申请锁,达到申请一次执行所有同步代码块。 从而提升了性能
示例代码:
public class MyTest5 {
private Object object = new Object();
public void method(){
synchronized (object){
System.out.println("hello");
}
synchronized (object){
System.out.println("word");
}
synchronized (object){
System.out.println("!");
}
}
}
共用了一个锁对象,所有此处的synchronized会发生粗化。因为最终的执行是发生在机器码中的,所有如果我们对类进行反编译,会发现锁会依然存在,这是编译期发生的优化,无法只观看到。不过jvm文档中有这部分的描述。
7.死锁的分析
几个概念
- 死锁:线程1等待线程2互斥持有的资源,而线程2也等待线程1互斥持有的资源,两个线程无法执行的情况
- 活锁:线程执行进行一个总是失败的操作,导致无法继续执行
- 饿死:线程一直被调度器延迟访问其所需要的资源,或调度器通过优先级进行调度,而总是有一个高优先级的线程进行执行, 导致线程无法执行。饿死也叫无限延迟。(实际不会发生这种情况,因为如果是优先级调度,那么每次进行调度后就会修改未执行线程的优先级。使未执行的线程的优先级在某一个时刻大于其他线程优先级从而获取的执行权限。这也是为什么Java中设置线程的优先级用处不大的原因)
死锁代码示例:
public class MyTestDeathLock {
private Object lock1 = new Object();
private Object lock2 = new Object();
public void MyMethod1(){
synchronized (lock1){
synchronized (lock2){
System.out.println("myMethod1 invoke");
}
}
}
public void MyMethod2(){
synchronized (lock2){
synchronized (lock1){
System.out.println("myMethod2 invoke");
}
}
}
public static void main(String[] args) {
MyTestDeathLock lock = new MyTestDeathLock();
Runnable runnable1 = () -> {
while (true){
lock.MyMethod1();
try {
Thread.sleep(100);
}catch (Exception e){}
}
};
Runnable runnable2 = () -> {
while (true){
lock.MyMethod2();
try {
Thread.sleep(100);
}catch (Exception e){}
}
};
Thread thread1 = new Thread(runnable1,"thread1");
Thread thread2 = new Thread(runnable2,"thread1");
thread1.start();
thread2.start();
}
}
jvisualVM检测死锁
执行上述代码会进入死锁。然后
会出现下面的界面
点击线程dump就会看到对死锁检测的
jstack进行死锁分析
先执行代码后再命令行输入jps -l获取线程的id
再通过jstack进行查看
拉到最后
可以看到和jvisualVM分析的结果相同。