并发编程系列之基础篇(五)—深入理解synchronized关键字

并发编程系列之基础篇(五)—深入理解synchronized

前言

大家好,牧码心今天给大家推荐一篇并发编程系列之基础篇(五)—深入理解synchronized的文章,希望对你有所帮助。内容如下:

  • 同步锁概要
  • synchronized 的特性
  • synchronized 的用法
  • synchronized 的实现
  • synchronized 的原理
  • synchronized 的优化

概要

并发编程中,有时候会碰到多个线程同时访问同一个共享,可变的资源,这个资源我们称为临界资源,如对象,变量,文件等数据。但由于多线程执行过程中顺序不可控,会存在并发不安全的问题。所以我们需要采用同步机制,在多线程的情况下,同一时刻,只能有一个线程访问临界资源。
java中提供了两种方式来实现同步互斥访问:synchronized 和 Lock,它们的本质都是加锁来保证互斥访问资源,本文主要介绍synchronized 的特性,使用以及底层原理。

synchronized 的特性

  • 原子性
    所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
    synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性

  • 可见性
    所谓可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。
    synchronized和volatile都具有可见性,其中synchronized对一个类或对象实例加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主内存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。
    而volatile的实现类似,被volatile修饰的变量,每当值需要修改时都会立即更新主存,主内存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。

  • 有序性
    所谓有序性值程序执行的顺序按照代码先后执行。
    synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

  • 可重入锁
    所谓可重入锁指的是一个线程拥有了锁仍然还可以重复申请此锁。
    synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。用伪代码表示如:

 private synchronized void method() {
       .....
       method();
       .....
    }

method()在获取到对象锁以后,在递归调用时不需要等上一次调用先释放锁后再获取锁,而是直接进入执行,这说明了synchronized的可重入性.。除了递归调用,调用同类的其它同步方法,调用父类同步方法,都是可重入的,前提是同一对象去调用。

synchronized 的用法

sychronized可以修复静态方法,成员函数,也可以修饰代码块。但是归根结底它上锁的资源只有两类:一个是对象实例,一个是类。下面我们用伪代码来说明具体用法:

public class SynchronizedTest {
    private int i=0;
    private static  int j=0;
    // 成员函数加锁,需要获取当前类实例对象的锁才能进入同步块。否则阻塞等待;
    public synchronized void add1(){
        i++;
    }
    // 静态方法加锁,需要获取当前类的锁才能进入同步块,
    public static synchronized void add2(){
        j++;
    }
    public void add3(){
        synchronized (this){
            // 代码块持有当前对象锁,需要获取当前类实例对象的锁才能进入同步块。否则阻塞等待
        }
        synchronized (SynchronizedTest.class){
            // 代码块持有当前类的锁,需要获取当前类的锁才能进入同步块。否则阻塞等待
        }
    }
}

首先我们知道被static修饰的静态方法、静态属性都是归类所有,同时该类的所有实例对象都可以访问。但是普通成员属性、成员方法是归实例化的对象所有,必须实例化之后才能访问,这也是为什么静态方法不能访问非静态属性的原因。我们明确了这些属性、方法归哪些所有之后就可以理解上面几个synchronized的锁到底是加给谁的了。

  • add1()方法没有被static修饰,也就是说该方法是归实例化的对象所有,那么这个锁就是加给SynchronizedTest类所实例化的对象。
  • add2()方法是静态方法,归SynchronizedTest类所有,所以这个锁是加给SynchronizedTest类的。
  • add3()方法中两个同步代码块,第一个代码块所锁定的是当前对象,锁是给当前实例化对象的;第二个代码块所锁定的是SynchronizedTest.class,该锁是加给SynchronizedTest类的。

弄清楚这些锁是上给谁的就应该很容易懂synchronized的使用啦,只要记住要进入同步方法或同步块必须先获得相应的锁才行。那么我下面再列举出一个非常容易进入误区的例子:

public class TestDemo implements Runnable {
    private static int j=0;

    @Override
    public void run() {
        for (int i=0;i<1000;i++){
            synchronized (this){
                j++;
            }
        }

    }
    public static void main(String[] args) throws Exception {
        Thread t1=new Thread(new TestDemo());
        Thread t2=new Thread(new TestDemo());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("执行结果:"+j);
    }
}

上面代码就是用两个线程分别对i加1000次,理论结果应该是2000,而且用了synchronized锁住当前对象的代码块,保证了其线程安全性。可执行结果存在小于2000的,原因在于synchronized加锁的是当前对象实例,但是在创建线程时却new了两个TestDemo实例,也就是说这个锁是给这两个实例加的锁,并没有达到同步的效果,所以才会出现错误。至于为什么小于2000,要理解i++的过程就明白了。

synchronized 的实现分析

synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。它有2中加锁方式:一个是对方法上锁,一个是构造同步代码块。这2种方式的执行过程都是一样。
1.执行同步块时先获取锁,获取到锁后,锁的计数器+1;
2.同步块执行完成后释放锁。锁的计数器-1;
3.如果获取失败就阻塞式等待锁的释放
这2种方式只是在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。我们用javap -verbose *.class 来反编译,来看具体看如下分析:

  • 同步方法
// 在方法上加锁,查看反编译后的字节码
public class SynchronizedMethod {
	public synchronized void method() {}
}

// javap -verbose SynchronizedMethod.class 执行得到
public synchronized void method();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 11: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  this   Lcom/greekw/thread/example/SynchronizedMethod;
  • 同步代码块
// 在代码块中加锁,并查看反编译后的字节码
public class SynchronizedThis {
	public void method() {
		synchronized(this) {}
	}
}
// 由javap -verbose SynchronizedThis.class 得到,然后找到method方法所在的指令块,可以清楚的看到其实现上锁和释放锁的过程
 public void method();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_1
         5: monitorexit
         6: goto          14
         9: astore_2
        10: aload_1
        11: monitorexit
        12: aload_2
        13: athrow
        14: return

从反编译的结果可以看到

  • 同步代码块是由monitorenter指令和monitorexit控制锁的获取和释放,但是为什么会有两个monitorexit呢?其实第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto指令,而该指令转向的就是14行的return,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。
  • 同步方法是ACC_SYNCHRONIZED标志控制,这标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1,如果获取失败就阻塞住,知道该锁被释放。

synchronized 的实现原理

在理解锁实现原理之前先了解一下HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。分布如图:
对象在内存中存储的布局图
图中我们可以看到对象头,实例数据,对齐填充位等元素分布,每个元素具体说明如下:

  • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等;
  • 实例数据:即创建对象时,对象中成员变量,方法等;
  • 对齐填充:对象的大小必须是8字节的整数倍;

这里重点分析下对象头,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由Mark Word 和 Class Metadata Address组成,其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息,Class Metadata Address是类型指针指向对象的类元数据,JVM通过该指针确定该对象是哪个类的实例。

  • 实现原理
    synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。当然JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(LockCoarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。

monitor(监视器)对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因

synchronized 锁的优化

JDK一直在对synchronized优化,其中最大的一次优化就是在jdk6的时候,新增了两个锁状态,通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升。
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁的升级路径是:无锁——>偏向锁——>轻量级锁——>重量级锁,锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。下图为锁的升级全过程:
锁的升级全过程

  • 偏向锁
    偏向锁的作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。
    核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
  • 轻量级锁
    轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。所以轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
  • 重量级锁
    重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。
  • 锁消除
    消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。通过这种
    方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除
  • 锁粗化
    锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。
  • 自旋锁与自适应自旋锁
    轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
    自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。
    自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。

参考

  • https://cloud.tencent.com/developer/article/1465413
发布了91 篇原创文章 · 获赞 27 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/xhwwc110/article/details/105703617