深入理解Synchronized(一)

一、简介

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是对象锁,那么它在锁实例对象和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关键字时,对应的字节码如下:

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

由此可见,同步方法是通过方法中的access_flags中设置ACC_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语言内置的管程中只有一个条件变量。模型如下图所示:

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中线程锁流转图如下:

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

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

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

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值