Java 并发(四)—— volatile 和 synchronized

一、volatile 关键字

1.概念

如果我们将一个变量使用 volatile 修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取

2.作用

保证变量对所有线程的可见性。但不能保证数据的原子性。因此不能完全保证线程安全

禁止指令重排序优化。volatile关键字在Java中主要通过插入内存屏障来禁止特定类型的指令重排序。

3.指令重排序

指令重排序的原理:

在执行程序时,为了提高性能处理器和编译器常常会对指令进行重排序。

指令重排序的原则:

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系的不允许重排序
双重校验锁实现对象单例模式(线程安全)

public class Singleton {
    private volatile static Singleton uniqueInstance;

    private Singleton() {
    }

    public  static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。

例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

uniqueInstance 的可见性才保证了 T2 看见 uniqueInstance,但由于没有禁止指令重排,没有保证uniqueInstance = new Singleton()这段代码的原子性,所以导致了线程不安全。

改进:如果想要保证上面的代码运行正确也非常简单,利用 synchronizedLock或者AtomicInteger都可以。

4.内存屏障

二、synchronized 关键字

1.概念

解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

2.使用

修饰实例方法 (锁当前对象实例)

修饰静态方法 (锁当前类)

修饰代码块 (锁指定对象/类)

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁

尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

3.底层原理

  • 实例方法:隐式调用moniterenter、moniterexit
  • 静态方法:隐式调用moniterenter、moniterexit
  • 同步代码块:通过moniterenter、moniterexit 关联到到一个monitor对象,进入时设置Owner为当前线程,计数+1、退出-1。除了正常出口的 monitorexit,还在异常处理代码里插入了 monitorexit。

monitorenter和monitorexit这两个jvm指令,主要是基于 Mark Word 和 Object monitor 来实现的。

在 Java 早期版本中,synchronized 属于 重量级锁直接关联到 monitor对象,效率低下。

在JDK 1.6后,为了提高锁的获取与释放效率,JVM引入了两种锁机制:偏向锁和轻量级锁。它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。这几种锁的实现和转换正是依靠对象头中的Mark Word

Mark Word

锁升级过程 

3.1 偏向锁

引入偏向锁的目的:在没有多线程竞争的情况下,尽量减少不必要的轻量级锁的执行。

轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只依赖一次CAS原子指令。

但在多线程竞争时,需要进行偏向锁撤销步骤,因此其撤销的开销必须小于节省下来的CAS开销,否则偏向锁并不能带来收益。

当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。但无论怎么优化,偏向锁的撤销仍有一定不可避免的成本。如果业务场景存在大量多线程竞争,那偏向锁的存在不仅不能提高性能,而且会导致性能下降(偏向锁并不都有利,jdk15默认不开启)。

只有匿名偏向的对象才能进入偏向锁模式。JVM启动时会延时初始化偏向锁,默认是4000ms。初始化后会将所有加载的Klass的prototype header修改为匿名偏向样式。当创建一个对象时,会通过Klass的prototype_header来初始化该对象的对象头。简单的说,偏向锁初始化结束后,后续所有对象的对象头都为匿名偏向(即对象头中的bit field存储的Thread ID为空)样式,在此之前创建的对象则为无锁状态。而对于无锁状态的锁对象,如果有竞争,会直接进入到轻量级锁。这也是为什么JVM启动前4秒对象会直接进入到轻量级锁的原因。

因此,我们可以明确地说,只有锁对象处于匿名偏向状态,线程才能拿到到我们通常意义上的偏向锁。而处于无锁状态的锁对象,只能进入到轻量级锁状态。

3.2 轻量级锁

引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁使用的操作系统互斥量带来的开销。

但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

3.3 重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其依赖于底层操作系统的Mutex Lock实现,需要额外的用户态到内核态切换的开销。


synchronized 和 volatile 有什么区别?

  • volatile 关键字只能用于变量,而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。

三、参考

浅析synchronized锁升级的原理与实现 - 小新成长之路 - 博客园 (cnblogs.com)

Java锁与线程的那些事 (youzan.com)

Java并发常见面试题总结(中) | JavaGuide

  • 8
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值