深入理解Synchronized(一)

梦想不抛弃苦心追求的人,只要不停止追求,你们会沐浴在梦想的光辉之中。再美好的梦想与目标,再完美的计划和方案,如果不能尽快在行动中落实,最终只能是纸上谈兵,空想一番。只要瞄准了大方向,坚持不懈地做下去,才能够扫除挡在梦想前面的障碍,实现美好的人生蓝图。深入理解Synchronized(一),希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com,来源:原文

一、简介

JMM(Java Memory Model,Java内存模型)规范定义Java的共享内存模型,但共享内存模型随之带来的就是共享变量的线程安全问题,对JMM的理解可以参看《Java 内存模型》这篇文章。

为了保证多线程对共享变量的互斥操作,Java提供了两大类型的方案,即阻塞式和非阻塞式的解决方案。阻塞的方式有Synchronized关键字和Lock锁,非阻塞的方式使用原子变量(CAS+自旋)。

而在Java中,互斥和同步都可以通过Synchronized来实现,但它们还是有区别的:

互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码

同步是由于线程执行的先后顺序不同,需要一个线程等待其他线程运行到某个点然后再唤起线程

临界区

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区,其共享资源为临界资源

竞态条件

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

Synchronized是Java提供的一种原子性的内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,所以Synchronized也称为对象锁,它的实现依赖于Java对象,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。

Synchronized基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。为此,JVM内置锁在JDK1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、偏向锁(Biased Lock)、轻量级锁(Ligthweight Lock)、自适应性自旋(Adaptive Spining)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。

下面是Open JDK官方对Synchronized的描述:

The Java® Language Specification
Each object is associated with a monitor (§17.1), which is used by synchronized methods (§8.4.3) and the synchronized statement (§14.19) to provide control over concurrent access to state by multiple threads (§17 (Threads and Locks)).
The Java® Virtual Machine Specification
The Java Virtual Machine supports synchronization of both methods and sequences of instructions within a method by a single synchronization construct: the monitor.

Java虚拟机通过一个同步结构支持方法和方法中的指令序列的同步:monitor

二、基本使用

Synchronized可以用来修饰方法,也可以用来修饰代码块

代码实例:

public synchronized  void increment(){

}

synchronized (this){
    //TODO
}

修饰方法时,既可以修饰实例方法,也可以修饰静态方法,但方法的访问限制符只能为public|protected|private

[public|protected|private] [static] synchronized void increment(){

}

修饰代码块时,可以是类实例对象,也可以是Class对象,也可以是任意实例对象Object

synchronized ([InstanceObject|Class|AnyObject]){

}

synchronized加锁方式与锁对象的关系如下:
深入理解Synchronized(一)
上面我们说了synchronized是对象锁,那么它在锁实例对象和Class对象有什么区别呢?结合下面的代码来理解

class User{

    private String userName;
    private int age;
    private String address;

    public synchronized void setUserName(String userName){
        this.userName = userName;
    }

    public synchronized void setAddress(String address){
        this.address = address;
    }

    public void setAge(int age){
        synchronized (User.class){
            this.age = age;
        }
    }
}

上面的User类中,提供了三个同步方法,setUserName()setAddress()是两个实例方法,所以锁的是实例对象,而setAge()方法中的同步代码块锁的是User类对象。

假设现在有User1和User2两个对象,User1在调用setUserName()方法的时候,User2也可以调用setUserName()方法,因为它们是不同的实例对象,它们之间并不存在同步关系。但如果User1在调用setUserName()方法时,它没法同时去调用setAddress()方法,会被阻塞,直到setUserName()方法执行完成后才会再去执行setAddress()方法

而对于setAge()方法,因为它锁是User类对象,每个类只有一个类对象,所以,当User1调用setAge()方法时,User2再去调用setAge()会被阻塞,直到User1完成调用,User2才被唤醒去执行setAge()方法

三、底层原理

3.1 字节码指令

synchronized可以用于方法和代码块,我们可以从字节码指令序列来看分别对应什么指令(idea可以安装jclasslib Bytecode Viewer插件来查看字节码指令)

下面是部分Java方法的访问标志:

深入理解Synchronized(一)

当我们在方法上使用synchronized关键字时,对应的字节码如下:

深入理解Synchronized(一)

上图中可以看到,increment()方法对应的访问标志为0x0021,而publicsynchronized对应的访问标志分别为0x00010x0020,正好就是这两个访问标志的和。

由此可见,同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现

当我们在代码块使用synchronized时,对应的字节码如下:

深入理解Synchronized(一)

同步代码块是通过moniterentermoniterexit来实现的,这两个指令的执行是JVM通过调用操作系统的互斥原语Mutex来实现的,被阻塞的线程被挂起、等待重新调度,会导致“用户态”和“内核态”两个态之间的来回切换,对性能有较大的影响。

moniterentermoniterexit可能并不是成对出现的,但moniterexit一定要多过moniterenter,因为要考虑执行异常的情况,也要通过moniterenter来释放锁。上面代码块的执行完第5行没有问题就跳转到14行,直接return了,出现异常的时候,才会去执行11行的moniterexit

3.2 Monitor

Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等高级语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。

MESA

在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。

现在广泛使用的是MESA模型,它的基本结构如下图所示:

image-20220110090013054

在MESA模型中,最主要的就是入口等待队列和条件变量等待队列,而每个条件变量都对应一个条件队列。

当多个线程访问共享变量时,只允许一个线程进入,其他线程在入口等待队列进行排队等待,保证了线程间的互斥。

在线程执行的过程中,当前线程可能需要等待其他线程的计算结果(条件),这个时候,当前线程就会进入到条件队列进行等待,当其他线程运行完得到计算结果之后,会唤醒条件队列的线程,这时,条件等待队列的线程会再次进入到入口等待队列中。条件变量和条件等待队列的作用是解决线程之间的同步问题。

wait()方法的正确使用姿势

对于MESA管程来说,有一个编程范式:

while(条件不满足){
    wait();
}

由于线程被唤醒的时候和获取到锁执行的时间是不一致的,被唤醒的线程在入口队列中经过排队等候重新获取到锁时,可能条件又不满足了,所以需要循环检验条件。MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。

notify()和notifyAll()的使用

满足以下条件时,可以使用notify(),其余情况尽量使用notifyAll()

  • 所有等待线程拥有相同的等待条件
  • 所有等待线程被唤醒后,执行相同的操作
  • 只需要唤醒一个线程

Java内置管程Synchronized

Java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简,在MESA模型中,条件变量可以有多个,而Java语言内置的管程中只有一个条件变量。模型如下图所示:

深入理解Synchronized(一)

Monitor机制在Java中实现

java.lang.Object类定义了wait()notify()notifyAll()方法,所以在Java中,所有对象都可以作为锁对象,这些方法的具体实现,依赖于ObjectMonitor,这是JVM内部基于C++实现的一套机制。

ObjectMonitor其主要数据结构如下(hosspot源码ObjectMonitor.hpp)

ObjectMonitor() {
    _header       = NULL; //对象头  markOop
    _count        = 0;  
    _waiters      = 0,   
    _recursions   = 0;   // 锁的重入次数 
    _object       = NULL;  //存储锁对象
    _owner        = NULL;  // 标识拥有该monitor的线程(当前获取锁的线程) 
    _WaitSet      = NULL;  // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
    _WaitSetLock  = 0 ;    
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
    FreeNext      = NULL ;
    _EntryList    = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}

_WaitSet是一个条件等待队列,所有调用wait()方法的线程都会放入到该队列中,_csq_EntryList是两个入口等待队列,不同的是,_cxq是一个栈结构,而_EntryList是一个链表结构,ObjectMonitor中线程锁流转图如下:

深入理解Synchronized(一)

当外部线程竞争时,会把竞争的线程插入到_cxq的头部,而释放锁时,根据策略会有所不同,默认策略(QMode=0)是:如果_EntryList为空,则把_cxq中的元素按照原有顺序插入到_EntryList中,并唤醒第一个线程,也就是当_EntryList为空时,是后来的线程先获取锁(非公平锁);如果_EntryList不为空,直接从_EntryList中唤醒锁

注:Synchronized只有处于重量级锁状态时,才会有ObjectMonitor对象,但Synchronized还有偏向锁、轻量级锁以及无锁这三种锁状态,这些锁以及锁膨胀过程会在后面的文章介绍。

思考:Synchronized加锁是加在对象上,那么锁对象是如何记录锁状态的?

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/153629.html

(0)
飞熊的头像飞熊bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!