深入理解volatile、Synchronized、ReentrantLock底层实现原理

前沿

我们都知道,编写正确的并发程序对于程序员来说具备一定的挑战,很多原因是因为我们对 Java并发机制的底层实现原理 没有足够的理解和认识,因此需要快速而又精准的解决并发类的疑难杂症,就需要理解并发编程的本质,追本溯源,深入分析并发机制的底层原理。

这些年,为了提高机器的运行性能,我们的CPU、内存、I/O设备不断的迭代,但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

而恰好我们在享受并发编程带来的良好的性能体验,也同时要面对并发编程带来的问题,而这些诡异问题的根据就来自这里!

一、导致并发问题的主要分为三个源头

只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发问题都会迎刃而出。

二、如何解决可见性和有序性问题

导致可见性的原因是缓存,导致有序性的原因是编译优化(指令的重排序造成的),那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是试想,如果真的采用这样的解决方案,我们的程序性能是不是就堪忧了?

合理的方案应该是按需禁用缓存以及编译优化,为了解决可见性和有序性问题,Java内存模型提供一些规范,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括volatile、synchronized和final三个关键字,以及六项Happens-Before规则。

三、volatile

3.1 volatile关键字作用

3.2 volatile底层实现原理

1. 内存可见性,保证变量的可见性: 当一个被volatile关键字修饰的变量被一个线程修改的时候,其他线程可以立刻得到修改之后的结果。当一个线程向被volatile关键字修饰的变量写入数据的时候,虚拟机会强制它被值刷新到主内存中。当一个线程用到被volatile键字修饰的值的时候,虚拟机会强制要求它从主内存中读取。

2. 禁止JVM指令重排序(防止JVM编译源码生成class时使用重排序): 指令重排序是编译器和处理器为了高效对程序进行优化的手段,它只能保证程序执行的结果是正确的,但是无法保证程序的操作顺序与代码顺序一致。这在单线程中不会构成问题,但是在多线程中就会出现问题。非常经典的例子是在双重检查创建单例方法中同时对字段加入volatile,就是为了防止指令重排序。

public class Singleton {
 
    private volatile static Singleton instance;
 
    private Singleton() {}
 
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
复制代码

由上可以看到,instance变量被 volatile关键字所修饰,但是如果去掉该关键字,就不能保证该代码执行的正确性。这是因为 instance = new Singleton(); 这行代码并不是原子操作,其在JVM中被分为如下三个阶段执行:

3.2 volatile的写-读内存语义

3.3 volatile总结

volatile用于确保变量的更新操作通知到其他线程

volatile变量自身具以下特性:

1 volatile保证该变量对所有线程可见,在一个线程修改了变量的值后,新的值对于其他线程是可以立即获取的;
2 volatile禁止指令重排序,即volatile变量不会被存储在寄存器或者其他处理器不可见的地方,因此在读取volatile变量总会返回最新写入的值。
3 对任意单个volatile变量的读/写具有原子性,但是类型volatile++这种复合操作不具备原子性。

即:volatile通过禁止指令重排序方式,保证了有序性,但是不能保证原子性。

四、JVM锁技术:synchronized

所谓原子性就是:一个或者多个操作在CPU执行的过程中不被中断的特性,称为“原子性”。原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?而操作系统做线程切换是依赖 CPU 中断的,所以禁止 CPU 发生中断就能够禁止线程切换。

在单核CPU的场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。

但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1上,一个线程执行在 CPU-2上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,那就有可能出现并发编程原子性问题。

同一时刻只有一个线程执行 这个条件非常重要,我们称之为互斥。 如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。

当谈到互斥,第一时间一定想到了那个杀手级解决方案:加锁,同时大脑中还会出现以下模型:

4.1 synchronized的作用范围:

1 synchronized作用于成员变量和非静态方法时,锁的是当时对象的实例。
2 synchronized作用于静态方法,锁的是当前Class实例,因为静态方法属于Class而不属于对象。
3 synchronized作用于方法块,锁的是synchronized括号里配置的对象。

这里简单的举个,当ynchronized用于 成员变量非静态方法 时,锁住的是当前对象的实例,具体代码实现如下:

自定义SynchronizedInstance类

@Data
public class SynchronizedInstance {
    private String id;
    private String syncInstanceName;

    public synchronized void method1() {
        for (int i = 0; i < 3; i++) {
            System.out.println("method1 execute..." + "i=" + i);
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public synchronized void method2() {
        for (int i = 0; i < 3; i++) {
            System.out.println("method2 execute..." + "i=" + i);
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
复制代码

运行MainTest#main()方法,观察控制台打印信息:

@Data
public class MainTest {

    public static void main(String[] args) {
        SynchronizedInstance synchronizedInstance = new SynchronizedInstance();
        new Thread(new Runnable() {
            @Override
            public void run() {
                //执行方法1
                synchronizedInstance.method1();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                //执行方法2
                synchronizedInstance.method2();
            }
        }).start();
    }
}
复制代码

上面SynchronizedInstance定义了两个使用synchronized修饰的普通方法,然后再main函数中定义对象的实例并发执行两个方法,从控制台可以明细看到,线程1会等待线程2执行完成后才能执行,这是引用synchronized锁住了当前对象实例synchronizedInstance导致的。

稍微把程序改一改,现在定义两个实例分别调用两个方法,程序就能并发执行了:

@Data
public class MainTest {

    public static void main(String[] args) {
        //实例1
        SynchronizedInstance synchronizedInstance = new SynchronizedInstance();
        //实例2
        SynchronizedInstance synchronizedInstance2 = new SynchronizedInstance();
        new Thread(new Runnable() {
            @Override
            public void run() {
                //实例1执行方法1
                synchronizedInstance.method1();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                //实例2执行方法2
                synchronizedInstance2.method2();
            }
        }).start();
    }
}
复制代码

观察控制台输出:

结果:method1和method2已经实现并发执行!

4.2 synchronized锁的几种用法和分析

1 代码块锁,新建一个对象:Object lock = new Object(),这里锁的是这个对象的下的Object对象,大部分人喜欢这样用,this是锁整个对象,范围比较大,可能造成该对象中其他加锁方法被干扰,所以可以用这种方式去防止大类对象被使用的时候造成死锁。

private Object lock = new Object();
/**
 * 锁对象lock
 */
public void methodA(){
    synchronized(lock){
        try {
            Thread.sleep(3000);
            System.out.println("---- 锁对象lock ---- ");
            
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
复制代码

2 代码块锁,锁类对象锁本身,不同的对象相互隔离,互不干扰。

/**
 * 锁对象本身
 */
public void methodB(){
    synchronized(this){
        try {
            Thread.sleep(3000);
            System.out.println("---- 代码块锁,锁类对象锁本身 ---- ");
            
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
复制代码

3 实例方法锁

/**
 * 实例方法锁,同一个实例,多线程阻塞
 * 不同的实例,互不干扰,因为锁的不是一个对象
 * 不同的实例,不同实例方法锁,多线程也是阻塞等待的,因为是一个对象
 */
public synchronized void methodC(){
    try {
        Thread.sleep(3000);
        System.out.println("---- 实例方法锁 ---- ");
        
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
复制代码

4 静态方法锁:

/**
 * static方法,类级别的锁,更对象this无关,
 * 同一个类中静态方法锁,多线程阻塞,等待获取类锁才能执行
 * 同一个类中不同静态方法锁,多线程阻塞,也需要等待获取类锁才能执行
 */
public synchronized static void methodD(){
    try {
        Thread.sleep(3000);
        System.out.println("---- 静态方法锁,类级别锁,锁的是当前类Class对象 ---- ");
        
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
复制代码

4.3 可重入性

当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,如果当前锁是重入性,请求将会成功,如果当前锁不是可重入性,会等待当前对象锁的释放,实际上该对象锁已被当前线程所持有,不可能再次获得,就会产生死锁,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,还有就是当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法,这就是synchronized的可重入性。

可重入原理:加锁次数计数器

4.4 synchronized的缺点

  1. 效率低:锁的释放情况少,试图获得锁时不能设定超时,不能中断一个正在试图获得锁的线程
  2. 不够灵活(读写锁更灵活:读不加,写才加):加锁和释放锁的时机单一,每个锁仅有单一的添加(某个对象),可能是不够的
  3. 无法知道是否成功获取到锁(lock可以去尝试成功了做一些逻辑业务,没成功做另一些逻辑)

4.5 synchronized总结

五、ReentrantLock

ReentrantLock是可重入锁,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁, ReentrantLock还支持获取锁的公平性和非公平性选择,公平锁指锁的分配和竞争机制是公平的,即遵循先到先得原则,非公平锁值JVM遵循随机、就近原则分配锁的机制。

5.1 ReentrantLock核心特性

ReentrantLock是一种可重入的排它锁,它主要是解决多线程多共享资源竞争的一个问题,它的核心特性有以下几个:

在ReentrantLock中,调用lock()方法获得锁;调用unlock()方法释放锁;ReentrantLock的实现依赖于Java的同步器框架AbstractQueuedSynchronizer(抽象队列同步器,简称AQS),AQS定义了采用volatile修饰的一个共享的整形变量state,主要是用来维护同步状态,AQS底层是采用一个双向链表来实现的,主要是用来存储并发请求线程的一个同步队列。

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
        static final class Node {
            //共享锁模式
            static final Node SHARED = new Node();
            //排他锁模式
            static final Node EXCLUSIVE = null;         
            static final int CANCELLED =  1;      
            static final int SIGNAL    = -1;          
            static final int CONDITION = -2;           
            static final int PROPAGATE = -3;
            //当前节点的前驱节点
            volatile Node prev;          
            //当前节点的后驱节点
            volatile Node next;
            //入队列的线程对象信息
            volatile Thread thread;  
            //链接到等待唤起的下一个节点
            Node nextWaiter;
            final boolean isShared() {
                return nextWaiter == SHARED;
            }
            //等待队列的头部
            private transient volatile Node head;
            //等待队列的尾部
            private transient volatile Node tail;
            //同步状态state
            private volatile int state;

    }
复制代码

ReentrantLock通过构造函数 ReentrantLock(boolean fair) 中传递不同的参数来定义不同类型的锁,默认的实现是非公平锁,因为非公平锁算放弃了锁的公平性,但是执行效率明显高于公平锁。

ReentrantLock lock = new ReentrantLock();      //参数默认是false,非公平锁
ReentrantLock lock = new ReentrantLock(true);  //公平锁
复制代码

如果是非公平锁,竞争锁不需要去判断AQS同步队列里面是否有等待的线程。

5.2 ReentrantLock支持锁重进入

重进入是只任意线程在获取到锁之后能够再次获取该锁而不被锁所阻塞,该特性的实现主要需要解决一下两个问题:

1 线程再次获得锁。 锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
2 锁的最终释放。 线程重复n次获取了锁,随后第n次释放后,其他线程能获取到该锁,锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0的时候表示锁已经成功释放。

六、synchronized和ReentrantLock的比较

共同点

不同点

展开阅读全文

页面更新:2024-04-29

标签:线程   原子   变量   底层   加锁   实例   公平   原理   对象   内存   代码   方法

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号

Top