synchronized与锁

375 阅读8分钟

这篇文章我们来聊一聊Java多线程里面的“锁”。

首先需要明确的一点是:Java多线程的锁都是基于对象的,Java中的每一个对象都可以作为一个锁。

还有一点需要注意的是,我们常听到的类锁其实也是对象锁。

Java类只有一个Class对象(可以有多个实例对象,多个实例共享这个Class对象),而Class对象也是特殊的Java对象。所以我们常说的类锁,其实就是Class对象的锁。

我们先来看下多线程下为什么会存在线程安全问题?

线程安全问题

一个变量 a, A 或者线程 B 单独访问并且修改变量 i 的值没有任何问题,那如果并行的修改变量 i ,那就会有安全性问题。

public class Test {

    public static int a = 0;

    public static void addA(){
        a++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
       		new Thread(new Runnable() {
                @Override
                public void run() {
                    addA();
                }
            }).start();
        }
        Thread.sleep(2000);
        System.out.println(Test.a);
    }

}

这个输出结果是不固定的,第一次可能是 98 ,第二次可能是 97 ,这个结果就和我们预期的结果不一致(预期结果是100),所以一个对象是否是线程安全的,取决于它是否会被多个线程访问,以及程序中是如何去使用这个对象的。如果 多个线程访问同一个共享对象,在不需额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态 依然是正确的(正确性意味着这个对象的结果与我们预期 规定的结果保持一致),那说明这个对象是线程安全的。

对于线程安全性,本质上是管理对于数据状态的访问,而且这个这个状态通常是共享的、可变的。

共享:是指这个 数据变量可以被多个线程访问;

可变:指这个变量的值在 它的生命周期内是可以改变的。

synchroinzed关键字

说到锁,我们通常会谈到synchronized这个关键字。它翻译成中文就是“同步”的意思。

我们通常使用synchronized关键字来给一段代码或一个方法上锁。它通常有以下三种形式:

  1. 修饰实例方法,锁是当前实例对象 ,进入同步代码前要获得当前实例的锁;
  2. 修饰静态方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁;
  3. 修饰代码块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
public class SynchroinzedDemo {

    /**
     * 对静态方法加锁
     */
    public static synchronized void test(){}
    /**
     * 对实例方法加锁
     */
    public synchronized void test1(){}
    /**
     * 对代码块加锁
     */
    public void test2(){
        synchronized(this){}
    }
}

对上面的代码加锁:

public class Test {

    public static int a = 0;

    public synchronized static void addA(){
        a++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
          new Thread(new Runnable() {
                @Override
                public void run() {
                    addA();
                }
            }).start();
        }
        Thread.sleep(2000);
        System.out.println(Test.a);
    }

}

经过多次运行,结果都是100,说明线程安全。

几种锁

Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁“。在Java 6 以前,所有的锁都是”重量级“锁。所以在Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,无锁在这里不再细讲。

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

关于锁降级有两点说明:

1.不同于大部分文章说锁不能降级,实际上HotSpot JVM 是支持锁降级的,文末有链接。

2.上面提到的Stop The World期间,以及安全点,这些知识是属于JVM的知识范畴,本文不做细讲。

下面分别介绍这几种锁以及它们之间的升级。

Java对象头

在 JVM 中,对象在内存中分为三块区域:

  • 对象头

    • Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
    • Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 实例数据

    • 这部分主要是存放类的数据信息,父类的信息。
  • 对其填充

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

      Tip:不知道大家有没有被问过一个空对象占多少个字节?就是8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。

      img

偏向锁

偏向锁是JDK6中引入的一项锁优化,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。

轻量级锁

如果明显存在其它线程申请锁,那么偏向锁将很快升级为轻量级锁。

自旋锁

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

重量级锁

指的是原始的Synchronized的实现,重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。

锁升级的场景

  • 场景1: 程序不会有锁的竞争。那么这种情况我们不需要加锁,所以这种情况下对象锁状态为无锁。

  • 场景2: 经常只有某一个线程来加锁。

    加锁过程:也许获取锁的经常为同一个线程,这种情况下为了避免加锁造成的性能开销,所以并不会加实际意义上的锁,偏向锁的执行流程如下:

  1. 线程首先检查该对象头的线程ID是否为当前线程;

  2. A:如果对象头的线程ID和当前线程ID一致,则直接执行代码; B:如果不是当前线程ID则使用CAS方式替换对象头中的线程ID,如果使用CAS替换不成功则说明有线程正在执行,存在锁的竞争,这时需要撤销偏向锁,升级为轻量级锁。

  3. 如果CAS替换成功,则把对象头的线程ID改为自己的线程ID,然后执行代码。

  4. 执行代码完成之后释放锁,把对象头的线程ID修改为空。

  • 场景3: 有线程来参与锁的竞争,但是获取锁的冲突时间很短。

当开始有锁的冲突了,那么偏向锁就会升级到轻量级锁;线程获取锁出现冲突时,线程必须做出决定是继续在这里等,还是回家等别人打电话通知,而轻量级锁的路基就是采用继续在这里等的方式,当发现有锁冲突,线程首先会使用自旋的方式循环在这里获取锁,因为使用自旋的方式非常消耗CPU,当一定时间内通过自旋的方式无法获取到锁的话,那么锁就开始升级为重量级锁了。

  • 场景4: 有大量的线程参与锁的竞争,冲突性很高。

我们知道当获取锁冲突多,时间越长的时候,我们的线程肯定无法继续在这里死等了,所以只好先休息,然后等前面获取锁的线程释放了锁之后再开启下一轮的锁竞争,而这种形式就是我们的重量级锁。

JVM 是如何实现 synchronized 的?

咱们先来看个 demo :

public class Demo {

    public void test(Object o){
        synchronized (o){

        }
    }
}

进入到 class 文件所在的目录下,使用 javap -v demo.class 来看一下编译的字节码(在这里我截取了一部分):

  public void test(java.lang.Object);
    descriptor: (Ljava/lang/Object;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=2
         0: aload_1
         1: dup
         2: astore_2
         3: monitorenter
         4: aload_2
         5: monitorexit
         6: goto          14
         9: astore_3
        10: aload_2
        11: monitorexit
        12: aload_3
        13: athrow
        14: return
      Exception table:
         from    to  target type
             4     6     9   any
             9    12     9   any

应该能够看到当程序声明 synchronized 代码块时,编译成的字节码会包含 monitorentermonitorexit 指令,这两种指令会消耗操作数栈上的一个引用类型的元素(也就是 synchronized 关键字括号里面的引用),作为所要加锁解锁的锁对象。如果看的比较仔细的话,上面有一个 monitorenter 指令和两个 monitorexit 指令,这是 Java 虚拟机为了确保获得的锁不管是在正常执行路径,还是在异常执行路径上都能够解锁。

关于 monitorentermonitorexit ,可以理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程指针:

  • 当程序执行 monitorenter 时,如果目标锁对象的计数器为 0 ,说明这个时候它没有被其他线程所占有,此时如果有线程来请求使用, Java 虚拟机就会分配给该线程,并且把计数器的值加 1;

    • 目标锁对象计数器不为 0 时,如果锁对象持有的线程是当前线程, Java 虚拟机可以将其计数器加 1 ,如果不是呢?那很抱歉,就只能等待,等待持有线程释放掉。
  • 当执行 monitorexit 时, Java 虚拟机就将锁对象的计数器减 1 ,当计数器减到 0 时,说明这个锁就被释放掉了,此时如果有其他线程来请求,就可以请求成功

为什么采用这种方式呢?是为了允许同一个线程重复获取同一把锁。 比如,一个 Java 类中拥有好多个 synchronized 方法,那这些方法之间的相互调用,不管是直接的还是间接的,都会涉及到对同一把锁的重复加锁操作。这样去设计的话,就可以避免这种情况。

总结

synchronized 关键字是通过 monitorenter 和 monitorexit 两种指令来保证锁的。

当一个线程准备获取共享资源时:

  • 首先检查 MarkWord 里面放的是不是自己的 ThreadID ,如果是,说明当前线程处于 “偏向锁”

  • 如果不是,锁升级,这时使用 CAS 操作来执行切换,新的线程根据 MarkWord 里面现有的 ThreadID 来通知之前的线程暂停,将 MarkWord 的内容置为空。

  • 然后,两个线程都将锁对象 HashCode 复制到自己新建的用于存储锁的记录空间中,接着开始通过 CAS 操作,把锁对象的 MarkWord 的内容修改为自己新建的记录空间地址,以这种方式竞争 MarkWord ,成功执行 CAS 的线程获得资源,失败的则进入自旋。

    • 自旋的线程在自旋过程中,如果成功获得资源(也就是之前获得资源的线程执行完毕,释放了共享资源),那么整个状态依然是 轻量级锁 的状态。
    • 如果没有获得资源,就进入 重量级锁 的状态,此时,自旋的线程进行阻塞,等待之前线程执行完成并且唤醒自己。