【Java】synchronized及其实现原理详解编程语言

synchronized简介

并行程序开发的一大关注重点就是线程安全,而synchronized是实现线程安全最简单的一种方法。

关键字synchronized的作用是实现线程间的同步。他的工作是对同步的代码加锁,使得每一次只能有一个线程进入同步块,从而保证线程间的安全性。

除了用于线程同步、确保线程安全外,synchronized还可以保证线程间的可见性和有序性。从可见性的角度上讲,synchronized完全可以替代volatile的功能,只是使用上没有那么方便。就有序性而言,由于synchronized限制每次只有一个线程可以访问同步块,所以被synchronized限制的多个线程是串行执行的。

synchronized的使用

关键字synchronized有多种用法,这里做一个简单的整理:

  • 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁;
  • 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁;
  • 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。

这里假设每一个线程对一个变量i自加10000000次,最终要保证每一个线程都加了这么多次。可以想见,如果没有同步机制,最终i肯定不是我们想要的那个数值。于是我们可以使用synchronized来进行同步,这里synchronized的加锁方法有很多,这就是上述的synchronized的几种用法。

1、作用于对象

/** 
 * Created by makersy on 2019 
 */ 
 
public class SynchronizedTest implements Runnable{
    
 
    static int i = 0; 
    static SynchronizedTest instance = new SynchronizedTest(); 
 
    @Override 
    public void run() {
    
        for (int j = 0; j < 10000000; ++j) {
    
            synchronized (instance) {
    
                i++; 
            } 
        } 
    } 
 
    public static void main(String[] args) throws InterruptedException {
    
        Thread t1 = new Thread(instance); 
        Thread t2 = new Thread(instance); 
        t1.start(); t2.start(); 
        t1.join(); t2.join(); 
        System.out.println(i); 
    } 
} 
 

这里synchronized作用的是instance这个对象,因此,每次当线程进入synchronized包裹的代码段,就都会要求请求instance实例的锁。如果当前有其他线程正持有这把锁,那么新到的线程就要继续等待。这样就保证了每次只能有一个线程执行i++操作。

2、作用于实例方法

 
/** 
 * Created by makersy on 2019 
 */ 
 
public class SynchronizedTest implements Runnable{
    
 
    static int i = 0; 
    static SynchronizedTest instance = new SynchronizedTest(); 
 
    public synchronized void increace() {
    
        i++; 
    } 
 
 
    @Override 
    public void run() {
    
        for (int j = 0; j < 10000000; ++j) {
    
            increace(); 
        } 
    } 
 
    public static void main(String[] args) throws InterruptedException {
    
        Thread t1 = new Thread(instance); 
        Thread t2 = new Thread(instance); 
        t1.start(); t2.start(); 
        t1.join(); t2.join(); 
        System.out.println(i); 
    } 
} 
 

用一个increase方法实现i++,synchronized直接作用于这个实例方法。也就是说在进入increase方法前,线程必须获得当前对象实例的锁。在此例中就是instance对象。要注意的是,两个线程传入的Runnable对象必须是同一个,否则两个线程获取的就不是同一把锁,因为这个锁是作用于实例的。

这里假如传入两个不同的 SynchronizedTest 对象,那么结果就会出现错误。仅仅修改main方法中2个Thread的传入参数,如下:

Thread t1 = new Thread(new SynchronizedTest()); 
Thread t2 = new Thread(new SynchronizedTest()); 

结果就不对了:12491868

如果要求传入实例不同,但是获得锁一样,那么就可以使用第三种方法。

3、作用于静态方法

/** 
 * Created by makersy on 2019 
 */ 
 
public class SynchronizedTest implements Runnable{
    
 
    static int i = 0; 
    static SynchronizedTest instance = new SynchronizedTest(); 
 
    private static synchronized void increace() {
    
        i++; 
    } 
 
 
    @Override 
    public void run() {
    
        for (int j = 0; j < 10000000; ++j) {
    
            increace(); 
        } 
    } 
 
    public static void main(String[] args) throws InterruptedException {
    
        Thread t1 = new Thread(new SynchronizedTest()); 
        Thread t2 = new Thread(new SynchronizedTest()); 
        t1.start(); t2.start(); 
        t1.join(); t2.join(); 
        System.out.println(i); 
    } 
} 
 

相比错误的实现,只是把increase方法改为了静态方法,这是利用了静态方法的特点,无论实例有多少个,静态方法只有一个,线程请求的还是当前类的锁,而不是当前实例的锁,因此,线程间还是可以正确同步。

synchronized底层实现原理

这里首先要了解在JVM堆内存中对象的布局是怎么样的,如下图:
在这里插入图片描述
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

对象头

重点要了解的是对象头,对象头的结构有两种情况:

  • 数组对象,虚拟机使用3个字宽存储对象头。
  • 非数组对象,则使用2个字宽来存储对象头。32位虚拟机中,1个字宽等于4字节,即32位。

具体结构是:

虚拟机位数 头对象结构 说明
32/64 bit Mark Word 默认存储对象的hashCode,分带年龄,锁类型,锁标志位等信息
32/64 bit Class Metadata Address 类型指针,指向对象的类元数据,JVM通过这个指针确定该对象是那个类的数据
32/64 bit Array Length (数组对象)数组长度

其中,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。
Mark Word内部结构如下所示:
在这里插入图片描述

Monitor

属于一种同步机制,通常被视为一个对象,所有Java对象都可以成为monitor,每一个Java对象一旦生成就会自带一把锁,这把锁被称为Monitor锁或内部锁。

任何一个对象都有一个monitor与之关联,当一个monitor被某个线程持有之后,该对象将处于锁定状态。同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

代码块同步是使用monitorenter和monitorexit指令实现。monitorenter和monitorexit指令是在编译后插入到同步代码块开始和结束的的位置。

monitorenter :每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit:执行monitorexit的线程必须是对象所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

在HotSpotJVM实现中,锁有个专门的名字:对象监视器。

synchronized锁优化

在JDK 1.6之前,synchronized被认为是重量级的,由于它的易用性导致了synchronized的滥用,这也是它常被诟病的原因。
从jdk1.6之后对synchronized进行了很多优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

自旋锁:通过让线程执行几次忙循环等待锁的释放,不让出CPU。

自适应自旋锁:自选次数不固定,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

锁消除:更彻底的锁优化,通过逃逸分析,若变量不会逃出某个作用域,就将该作用域中的无用锁去除

锁粗化:遇到连续的对同一个锁加锁解锁,扩大加锁范围,避免反复加锁解锁

偏向锁:减少同一线程获取锁的代价。若一个线程获得锁,那么锁进入偏向模式,此时Mark Word结构也变为偏向锁结构,下次获取锁只需要检查Mark Word的锁标记位为偏向锁以及当前线程id为Mark Word的Thread ID即可。不适合锁竞争比较激烈的多线程场合。

轻量级锁:适合线程交替执行同步块。若有同一时间访问同一锁的情况,且自旋了一定次数还没获得锁,就会导致轻量级锁膨胀为重量级锁。

synchronized四种状态

锁膨胀方向:无锁->偏向锁->轻量级锁->重量级锁

原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/19356.html

(0)
上一篇 2021年7月19日
下一篇 2021年7月19日

相关推荐

发表回复

登录后才能评论