聊聊Synchronized关键字

555 阅读7分钟

「这是我参与2022首次更文挑战的第10天,活动详情查看:2022首次更文挑战」。

今天想聊聊我学习Synchronized的一些心得,Java为了解决并发编程中存在的原子性、可见性和有序性等问题,提供了一系列和并发处理相关的关键字,其中就有synchronized,简单来说这个关键字的作用就是在多线程的情况下确保这个关键字作用的类或对象是线程安全的。

先来看一个例子:

class synchronizedTest {
    public static int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        synchronizedTest test = new synchronizedTest();
        for (int i = 0; i < 10; i++) {
        //初始化10个线程执行累加操作
            new Thread(() -> {
                for (int j = 0; j < 1000; j++)
                    test.increase();
                System.out.println(test.inc);
            }).start();
        }
    }
}

对于上述的例子来说我们先初始化了一个类,然后用十个线程去执行这个类的increase() 方法,正常来讲结果应该是10000,但实际运行出来基本上都会比10000要小,我们可以看一下十个线程的输出结果:

image.png

可以看到最后的结果并不是10000,原因在于inc++ 这个操作并不是原子的,他其实是

inc=inc+1

所以说这是分三部来完成的,首先拿到inc的值,然后把这个值加1,最后在赋值给inc,这就导致了在多线程的情况下会出现这样一种情况:存在两个甚至两个以上的线程同时到了相同值的inc对其加1再赋值,最后的结果就是inc的值会比预期的要小,那解决的办法是什么呢?我们在increase() 方法上加上synchronized 关键字再来看结果。

public synchronized void increase() {
    inc++;
}

可以看到最后的结果是正确的,synchronized 关键字的作用就是确保了inc是线程安全的,任意时刻只能有一个线程对其进行操作,所以不会存在像之前一样的重复操作。

image.png

一、synchronized 用法

在刚刚的例子中我用synchronized关键字修饰了普通方法,当 synchronized 修饰普通方法时,被修饰的方法被称为同步方法,其作用范围是整个方法,作用的对象是调用这个方法的线程,同一时间内只有一个线程可以成功的调用这个方法。除此之外,synchronized可以用来修饰静态方法和代码块

静态方法

修饰静态方法,其作用的范围是整个方法,作用对象是调用这个类的线程。

/**
 * synchronized 修饰静态方法
 */
public static synchronized void staticMethod() {
    // .......
}

代码块

在代码块中修饰的加锁对象既可以是对象也可以是类,两者是有一定区别的

synchronized (xxxx.class||this) { // ...... }

将之前的例子修改一下来看看这两者到底有什么区别,首先来看对class加锁后多个线程调用同一个类的实例:

class synchronizedTest {
    
    public synchronized void increase() {
        synchronized (synchronizedTest.class) {
            System.out.println(String.format("当前线程:" + Thread.currentThread().getName() +
                    " 执行时间:" + new Date()));
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        synchronizedTest test = new synchronizedTest();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                test.increase();
            }).start();
        }
    }
}

结果是显而易见的这十个线程共享的是同一把锁,同一时间内只有一个线程可以去调用increase()方法

image.png

那如果是十个线程调用不同的对象呢,修改一下代码在每个线程中创建类的对象在调用方法:

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            synchronizedTest test = new synchronizedTest();
            test.increase();
        }).start();
    }
}

结果和之前一样,这说明虽然是不同的对象但是还是用的同一把锁。

image.png

将加锁对象class改为this后再进行相同操作:

public synchronized void increase() {
    synchronized (this) {
        System.out.println(String.format("当前线程:" + Thread.currentThread().getName() +
                " 执行时间:" + new Date()));
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

操作同一对象时结果与之前一样,但操作不同的对象时结果出现了变化

image.png

可以看到每个线程之间都是独立的锁,所以说当使用 synchronized 加锁 class 时,无论共享一个对象还是创建多个对象,它们用的都是同一把锁,而使用 synchronized 加锁 this 时,只有同一个对象会使用同一把锁,不同对象之间的锁是不同的。

二、实现原理

讲完了用法后,再来看一看这个加锁具体是如何实现的,其中涉及到一个很关键的内容——monitor,synchronized通过内部对象Monitor(监视器锁)来实现,通过进入与退出monitor对象来实现方法与代码块的同步,在字节码层面来看,就是靠monitorentermonitorexit指令实现的,最终依赖操作系统的Mutex lock(互斥锁)。所谓同步在我看来应该是多个线程有序的访问加了锁的代码块或者说是持有锁的对象,但是比起同步我觉得互斥的访问资源更为的形象,就是说同一时间只能由单个线程持有。

image.png

把Synchronized编译以后就会有两个指令,分别是monitorentermonitorexitmonitorenter插入到同步代码块的开始位置,monitorexit会插入到结束的位置之外还会插入到异常的位置,确保发生异常是这个锁也可以正常的解除。

# Java 中的 Monitor 机制这篇文章详细讲解了monitor机制。monitor 的重要特点是在同一个时刻,只有一个线程能进入 monitor 中定义的临界区(被Synchronized修饰的区域),这使得 monitor 能够达到互斥的效果。除此之外还可以实现其他线程的阻塞和唤醒。

简单的理解就是每个对象都拥有自己的监视锁Monitor,线程只有拿到了锁,才可以执行代码。从下图中可以看到他的一个工作流程,当一个线程想要获取monitor锁进行操作时会先进入EntrySet 中进行等待,如果拿到了这个锁就变成了owner,拥有了这个锁以后线程就可以干自己想干的事情,这时候面临这两种情况:第一种是这次的事情做完了那可以exit退出把owner让给别的线程,另一种是事情还没做完但是被一些外部条件打断了这时候线程就会被放到WaitSet中进行等待,等到下次有机会再去拿锁把没干完的事情接着干。

image.png

大致流程如下:

  1. 当我们进入一个方法的时候,执行monitorenter,就会获取当前monitor的所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitor的owner。
  2. 如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1,如果你不是owner没有所有权的话就被移交操作系统,但是会引起操作系统模式的转换带来较大的开销。
  3. 同理,当他执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有。

三. Java对象头

上面说到用Synchronized的时候涉及到了操作系统模式的转换,所以Synchronized被称做是重量级锁,他的性能并不是很好,不过在JDk1.6的时候对锁进行了一次优化,在了解优化之前要先了解一下java对象头部的组成。对象组成分为3个区域:对象头、实例数据、对齐填充。

1.对象头

对象头包括两部分信息,第一部分为Mark Word,第二部分为class pointer,如果是对象类型为数组,那么还有数组长度作为第三个部分。

Mark Word(标记字段): 会存储对象的很多信息,具体内容如下图所示:

image.png

可以看到对象携带的自身锁的信息就在MarkWord中存储,这一部分的记录数据是根据锁的状态不断变化的。

Klass Point(类型指针) :对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

2.实例数据

实例数据就是对象真正存储的数据区,各种类型的字段内容。

3.对齐填充

这部分不是必须存在,只是起着占位符的作用,主要是因为内存管理要求对象的大小必须是8字节的整数倍,而对象头正好是8个字节的整数倍,但是实例数据不一定,所以需要对齐填充补全。所以对一个空对象来说占用的刚好就是8字节。

四、锁的升级

在jdk1.6之前凡是碰上Synchronized那就是重量级锁,效率不是很高,所以在1.6的时候推出了一波优化,出现了锁的升级的过程,在重量级锁之前产生了轻量级锁和偏向锁,这两个锁默认开启了自旋锁,不会引起线程的阻塞,所以性能自然要优于重量级锁,但是我能力有限没有办法讲的很清楚。

具体升级的过程可以看这篇文章:# 锁升级过程(偏向锁/轻量级锁/重量级锁)

还有这篇文章把偏向锁讲的很透彻:# 难搞的偏向锁终于被 Java 移除了