并发编程-synchronized关键字大总结

0、synchronized 的特点:

可以保证代码的原子性和可见性。

1、synchronized 的性质:

可重入(可以避免死锁、单个线程可以重复拿到某个锁,锁的粒度是线程而不是调用)、不可中断(其实也就是上面的原子性)

2、synchronized 的分类:

  • 按照作用对象划分为 对象锁类锁
  • 按照作用位置划分为 代码块方法(静态和非静态)
  • 按照具体细节划分为 实例(普通方法)同步方法静态同步方法实例方法中的同步代码块静态方法中的代码块

如果从类是 Class 对象的角度看,类锁也是对象锁,但上面这样划分的解释性更好。

3、使用 synchronized 进行线程同步的七种情况: 七种情况的测试代码见 这里 GitHub上

  • 1、多个线程同时访问一个对象的同步方法,因为多个线程要竞争这个对象的对象锁,没有拿到对象锁的线程进入阻塞状态,所以多个线程将串行执行这个方法,线程安全。
  • 2、多个线程访问的是多个对象的同步方法,因为每个线程都可以拿到对于对象的对象锁,所以多个线程可并行(多核处理器)执行这个方法,各个线程互不影响。
  • 3、多个线程访问的是 synchronized 的静态方法,因为多个线程要竞争对象对应的类对象(Class 对象)的类锁,没有拿到类锁的线程进入阻塞状态,所以多个线程将串行执行这个方法,线程安全。
  • 4、多个线程同时访问一个对象的同步方法与非同步方法,因为线程执行这个对象的同步方法需要拿到对象锁,而非同步方法不需要锁,所以多个线程将串行执行同步方法,同步方法线程安全,非同步方法多个线程可以并行访问。
  • 5、访问一个对象的不同的普通同步方法,因为对象锁只有一把,线程执行同步方法时都需要拿到对象的对象锁,所以只有当一个线程把所有的同步方法都执行完(对象锁的可重入性)后,这个对象锁才被释放,能够被其他线程拿到。
  • 6、多个线程同时访问静态 synchronized 和非静态 synchronized 方法,因为线程访问静态 synchronized 方法时需要拿到对象对应的类对象的类锁,访问非静态 synchronized 方法时需要拿到对象的对象锁,所以多个线程访问静态 synchronized 方法和非静态 synchronized 方法时需要竞争这两把锁。
  • 7、线程在执行同步方法时抛出异常,会自动释放锁,以便其他线程可以拿到锁继续执行。

4、synchronized 的相关原理:

加解锁原理、可重入原理、可见性原理

从反编译看加解锁原理:

> 对于用 Java 编写的代码,最常见的同步形式可能是 synchronized 方法。通常不使用 monitorenter 和 monitorexit 实现同步方法,而是通过 ACC_SYNCHRONIZED 标志在运行时常量池中进行简单区分,该标志由方法调用指令检查。 > > —— Java 虚拟机规范

同步代码块形式:

public class SynchronizedCodeBlock {
    public void method() {
        synchronized (this) {
            // 空
        }
    }
}

使用 javap -verbose SynchronizedCodeBlock.class 命令反编译结果如下图: [图片上传失败...(image-ca9ec-1547625053694)]

同步代码块形式,使用 monitorenter 和 monitorexit 指令显式对代码块加解锁。

同步方法形式:

public class SynchronizedMethod {
    public synchronized void method() {
        // 空        
    }
}

使用javap -verbose SynchronizedMethod.class 命令反编译结果如下图:

[图片上传失败...(image-89e72a-1547625053695)]

同步方法形式,使用 ACC_SYNCHRONIZED 标记隐式对方法加解锁。

方法测试可重入性原理:

代码:

public class ReentrancyTest {
    public synchronized void method1() {
        System.out.println("线程" + Thread.currentThread().getName() + "执行 method1");
        method2(); // 测试可重入性
    }
    
    public synchronized void method2() {
        System.out.println("线程" + Thread.currentThread().getName() + "执行 method2");
    }
    
    public static void main(String[] args) throws InterruptedException {
        Reentrancy reentrancy = new Reentrancy();
        // 方法体执行对象
        Runnable run = ()-> {
            reentrancy.method1();
        };
        Thread thread1 = new Thread(run);
        Thread thread2 = new Thread(run);
        // 启动线程
        thread1.start();
        thread2.start();
        // 主线程等待子线程执行完毕
        thread1.join();
        thread2.join();
        System.out.println("Finished");
    }
}

输出结果为:

线程Thread-0执行 method1
线程Thread-0执行 method2
线程Thread-1执行 method1
线程Thread-1执行 method2
Finished

JVM 负责跟踪对象被加锁的次数,线程第一次给对象加锁的时候,monitor 的计数变为 1,每当这个相同的线程在此对象上再次获得锁时,计数为递增。当任务离开时,monitor 的计数减 1,当计数为 0 时,锁被完全释放。

图说明可见性原理:

JMM 是 Java 内存模型的缩写,直接看图。

[图片上传失败...(image-603948-1547625053695)]

5、synchronized 的缺陷:

  • 效率低:锁释放情况少(一种是代码正常运行结束释放锁,另一种是产生异常释放锁),试图获得锁时不能设定超时,不能中断一个正在试图获得锁的线程。
  • 不够灵活(相比于读写锁,读操作不需要加锁,写操作才需要加锁),加锁和释放的时机单一,每个锁仅有单一的条件(某个对象)可能是不够的。
  • 无法知道是否成功获取到了锁。

6、synchronized 常见的面试题:

1、使用注意点:锁对象不能为空(如果是对象,可以用对象自身的锁,还可以自己造一个锁来让线程竞争)、锁的作用域不易过大、避免死锁(可以用 synchronized 模拟死锁)。

2、如何选择 Lock 和 Synchronized 关键字?

一切选择都要根据具体的业务需要,选择更合适的方式,减少出错。如果有现成的包(java.util.concurrent)和类(原子类、ConcurrentHashMap、CountDownLatch),就不要自己造轮子了,直接拿来用,Java 原生支持的效率也会更高些,如果真的要用到 Lock 或者 Synchronized 独有的特性,再来考虑这两个。

3、多线程访问同步方法的各种具体情况(也就是上面的七种情况)。

7、对 synchronized 的思考(面试可能也会被问到):

1、多个线程等待同一个 synchronized 锁时,JVM 如何选择下一个获取锁的是哪个线程?

这个问题就涉及到内部锁的调度机制,线程获取 synchronized 对应的锁,也是有具体的调度算法的,这个和具体的虚拟机版本和实现都有关系,所以下一个获取锁的线程是事先没办法预测的。

2、synchronized 使得同时只有一个线程可以执行,性能较差,有什么方法可以提升性能?

  • 优化 synchronized 的使用范围,让临界区的代码在符合要求的情况下尽可能的小。
  • 使用其他类型的 lock(锁),synchronized 使用的锁经过 jdk 版本的升级,性能已经大幅提升了,但相对于更加轻量级的锁(如读写锁)还是偏重一点,所以可以选择更合适的锁。

3、如何想要更加灵活的控制锁的获取和释放,怎么办?

可以根据需要实现一个 Lock 接口,这样锁的获取和释放就能完全被我们控制了。

4、什么是锁的升级、降级?

JDK6 之后,不断优化 synchronized,提供了三种锁的实现,分别是偏向锁、轻量级锁、重量级锁,还提供自动的升级和降级机制。对于不同的竞争情况,会自动切换到合适的锁实现。当没有竞争出现时,默认使用偏斜锁,也即是在对象头的 Mark Word 部分设置线程ID,来表示锁对象偏向的线程,但这并不是互斥锁;当有其他线程试图锁定某个已被偏斜过的锁对象,JVM 就撤销偏斜锁,切换到轻量级锁,轻量级锁依赖 CAS 操作对象头的 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则进一步升级为重量级锁。锁的降级发生在当 JVM 进入安全点后,检查是否有闲置的锁,并试图进行降级。锁的升级和降级都是出于性能的考虑。

5、什么是 JVM 里的偏向锁、轻量级锁、自旋锁、重量级锁?

偏向锁:在线程竞争不激烈的情况下,减少加锁和解锁的性能损耗,在对象头中保存获得锁的线程ID信息,如果这个线程再次请求锁,就用对象头中保存的ID和自身线程ID对比,如果相同,就说明这个线程获取锁成功,不用再进行加解锁操作了,省去了再次同步判断的步骤,提升了性能。

轻量级锁:再线程竞争比偏向锁更激烈的情况下,在线程的栈内存中分配一段空间作为锁的记录空间(轻量级锁对应的对象的对象头字段的拷贝),线程通过CAS竞争轻量级锁,试图把对象的对象头字段改成指向锁记录的空间,如果成功就说明获取轻量级锁成功,如果失败,则进入自旋(一定次数的循环,避免线程直接进入阻塞状态)试图获取锁,如果自旋到一定次数还不能获取到锁,则进入重量级锁。

自旋锁:获取轻量级锁失败后,避免线程直接进入阻塞状态而采取的循环一定次数去尝试获取锁。(线程进入阻塞状态和非阻塞状态都是涉及到系统层面的,需要在用户态到内核态之间切换,非常消耗系统资源)实验证明,锁的持有时间一般是非常短的,所以一般多次尝试就能竞争到锁。

重量级锁:在 JVM 中又叫做对象监视器(monitor),锁对象的对象头字段指向的是一个互斥量,多个线程竞争锁,竞争失败的线程进入阻塞状态(操作系统层面),并在锁对象的一个等待池中等待被唤醒,被唤醒后的线程再次竞争锁资源。

6、使用 synchronized 实现双重校验锁的单例模式?

单例对象加上 volatile 关键字,可以禁止JVM对指令重排序,因为下面第一处的代码 doubleCheckSingleton = new DoubleCheckSingleton(); 分为三个步骤:1、为 doubleCheckSingleton 分配内存空间;2、初始化 doubleCheckSingleton 对象的值;3、doubleCheckSingleton 指向分配的内存地址,所以执行的步骤可能变成 1、3、2,可能创建多个单例对象,所以给单例对象加上 volatile 关键字是很有必要的。

双重校验是指两次检查,一次是检查单例对象是否创建好了,如果还没有创建好,就第一次创建单例对象时,并在创建过程中锁住单例类(类锁),第二次的检查避免了一个线程在创建单例对象的过程中,也有其他线程也已经通过第一次非 null 判断,当这个线程释放类锁后,其他线程不知道单例对象已经创建好了,而再次创建。在第一次创建实例对象时才需要双重校验,synchronized 才有用武之地,后面只需要一次校验,提高了性能。

代码:

public class DoubleCheckSingleton {
    // 单例对象
    private volatile static DoubleCheckSingleton doubleCheckSingleton;
    // 构造函数私有化
    private DoubleCheckSingleton() {}
    // 获取单例对象的方法
    public static DoubleCheckSingleton getInstance() {
        if(doubleCheckSingleton == null) {
            synchronized (DoubleCheckSingleton.class) {
                if (doubleCheckSingleton == null) {
                    doubleCheckSingleton = new DoubleCheckSingleton(); // 第一处
                }
            }
        }
        return doubleCheckSingleton;
    }
}

欢迎访问个人博客https://wenshixin.gitee.io/blog/

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券