synchronized与volatile

在一个程序中运行多个线程本身不会导致线程安全问题, 问题在于多个线程同时处理一个资源而导致了其与预期结果不一致, 这即是线程安全问题. 实际上这些问题只有在多个线程向这些资源做了写操作才可能发生, 只要资源没有发生变化, 多个线程读取相同的资源就是安全的.

1. 简介

在JAVA中, 所有的变量都是保存在主内存中并且每个线程都有自己独立的工作内存. 线程需要读写主内存的共享变量时需要先将该变量拷贝一份副本到自己的工作内存, 然后在自己的工作内存中对该变量进行所有操作, 线程工作内存对变量副本完成操作之后需要将结果同步至主内存.

JMM

JMM内存模型

大致了解JAVA内存模型之后, 需要了解并发的3个核心概念.

  1. 原子性: 和数据库事务的原子性概念差不多, 即一个操作(有可能包含有多个子操作)要么全部执行(生效), 要么全部都不执行(都不生效).
  2. 可见性: 当多个线程并发访问共享变量时, 一个线程对共享变量的修改, 其它线程能够立即看到.
  3. 有序性: 程序执行的顺序按照代码的先后顺序执行. 因为处理器为了提高程序运行效率, 可能会对输入代码进行优化, 它不保证程序中各个语句的执行先后顺序同代码中的顺序一致, 但是它会保证程序最终执行结果和代码顺序执行的结果是一致的-即指令重排序.

在上一张图的例子中, 主内存中的变量是共享的, 所有线程都可以读写, 而线程工作内存又是线程私有的, 线程间不可互相访问. 那在多线程场景下, 图上的线程A和线程B同时操作共享内存里的同一个变量, 那么主内存内的变量数据就会被破坏. 也就是说主内存内的此变量不是线程安全的.

2. synchronized

保证方法或代码块操作的原子性

synchronized保证方法内部或代码块内部资源(数据)的互斥访问. 即同一时间, 最多只能有一个线程在访问. 具体而言, 就是被synchronized关键字描述的方法或代码块在多线程环境下同一时间只能由一个线程进行访问, 在持有当前锁的线程执行完成之前, 其他线程想要调用相关方法就必须进行排队, 直到持有当前锁的线程执行结束, 释放锁, 下一个线程才可获取锁并执行.

保证监视资源的可见性

保证多线程环境下对监视资源的数据同步. 即任何线程在获取到锁后的第一时间, 会先将共享内存中的数据复制到工作的缓存中; 任何线程在释放锁的第一时间, 会先将缓存中的数据复制到共享内存中.

保证线程间操作的有序性

synchronized的原子性保证了由其描述的方法或代码操作具有有序性, 同一时间只能由最多只能有一个线程访问, 不会触发JMM指令重排机制.

2.1 对象内存布局

对象内存布局

上图是JVM中每个对象在被创建之后的内存模型. 一共可以分为4个部分.

  • markword: 一共占用8个字节, 存储了hascode, gc信息, 锁信息. 当我们进行synchronized加锁时, 其实是修改了markword的内容.
  • klass pointer: 指向该对象的类.
  • 实际数据: 类成员变量所占用的位置, 图中没有详细的画出来.
  • padding: 对象补齐. 一个对象占用的大小必须能被8整除, 如果无法整除, 需要padding进行补位.

对象头

2.2 synchronized升级

synchronized在JDK 1.6之后, 进行了大量的优化. 在获取锁时, 需要一步步进行升级. 可以分为偏向锁, 轻量级锁, 以及重量级锁. 其中偏向锁, 轻量级锁都是属于用户态的锁, 而重量级锁属于内核态. 因此获取重量级锁消耗的性能比其他锁的消耗都要大很多.

偏向锁

在代码进入同步块的时候, 如果同步对象锁状态为无锁状态, 尝试使用CAS操作将markword中的threadId替换为当前线程, 如果CAS操作成功, 那么这个线程就拥有了该对象的锁. 如果CAS操作失败, 说明另外一个线程已经获取了偏向锁, 此时需要撤销另外一个线程获得的偏向锁, 并升级为轻量级锁. 偏向锁比较复杂, 此处不展开说明.

当超过一个线程竞争某个对象时, 会发送偏向锁的撤销操作. 在偏向锁被撤销之后, 对象可能处于不可偏向的无锁状态以及不可偏向的已锁状态. 原因是在撤销时:

  1. 原来获取偏向锁的线程已经执行完毕, 对象处于闲置状态, 此时对象直接被转换成不可偏向的无锁状态.
  2. 原先获取偏向锁的线程还没有执行完毕, 偏向锁依旧有效, 此时对象就应该被转换为轻量级加锁的状态.
轻量级锁

JVM先在当前线程的工作内存中创建一个空间用来存储锁记录, 然后把对象头中的markword复制到该锁记录中. 然后线程使用CAS操作尝试将对象头中的markword替换为指向锁记录的指针. 如果成功, 说明当前线程获取到了轻量级锁. 如果失败, 先进行自旋操作, 再次尝试CAS争抢. 如果仍然未争抢到, 膨胀为重量级锁. 在锁的持有者执行完毕后, 尝试使用CAS操作替换markword, 替换成功表示锁释放成功, 整个流程结束. 替换失败说明执行过程中, 有线程尝试获取锁但是没有成功, 轻量级锁升级为重量级锁, 此时释放轻量级锁之后, 还需要唤醒等待的线程.

3. volatile

JAVA中volatile关键字用来确保变量的更新操作可以通知到其他线程, 从而保持了可见性. 当使用volatile修改某变量后, 编译器与运行时都会注意到这个变量是共享的, 因此不会将该变量上的操作与其他内存操作一起重排序. 如果需要详细了解volatile, 还需要了解appens-before规则, as-if-serial语义以及内存屏障.

DCL(double check lock)单例是否需要添加单例?
public static class Single {
    private static volatile Single instance;

    public static Single getInstance() {
        if(instance == null) {
            synchronized(Single.class) {
                if(instance == null) {
                    instance = new Single();
                }
            }
        }
        return instance;
    }
}
以上代码是一个比较简单的单例. 上述的例子中instance是否需要使用volatile修改?

解答这个问题, 首先需要了解在JAVA中, 一个对象创建的过程.

public class A {

    public static void main(String[] args){
         Object a = new Object();
    }
}

将以上JAVA文件编译为class文件后, 分析他的字节码. 可以发现一个对象的创建过程分为三步.

  1. 虚拟机为对象在堆中分配内存. 此时类中的所有属性都是默认值
  2. 调用对象的init方法.
  3. 变量与堆内存中对象建立连接

如果不加volatile关键字, 可能会出现instance变量分配完内存, 但未执行init方法, 即对象初始化到一半的情况, 此时如果另外一个线程进入第一个instance == null的判断, 会直接返回false并返回instance对象, 此时的线程并没有初始化内部的属性, 从而导致该线程访问的都是默认值.

4. JUC之Lock

Lock是java.util.concurrent包下的一个接口, 定义了一系列的锁操作方法. Lock接口主要有ReentrantLock、ReentrantReadWriteLock.ReadLock、ReentrantReadWriteLock.WriteLock等实现类. 与 Synchronized不同是: Lock提供了获取锁和释放锁等相关接口, 使得使用上更加灵活, 同时也可以做更加复杂的操作. 关于Lock的具体说明会在后续的文章中详细介绍.

posted on 2021-02-18 14:17  annwyn  阅读(57)  评论(0)    收藏  举报

导航