Java多线程复习(一):JMM、volatile、synchronized、happens-before

一、JMM(Java内存模型)

在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响

JMM本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

在这里插入图片描述

如果线程A与线程B之间要通信的话

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去

2)线程B到主内存中读取线程A之前已更新过的共享变量

在这里插入图片描述

1、可见性

各个线程对主内存中共享变量的操作都是各个线程拷贝到自己的本地内存进行操作后再写回到主内存中的

这就可能存在线程A修改了共享变量x的值还未回写主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题

2、原子性

count+=1,至少需要三条CPU指令

  • 指令1:首先,需要把变量count从内存加载到CPU的寄存器
  • 指令2:之后,在寄存器中执行+1操作
  • 指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)

操作系统做任务切换,可以发生在任何一条CPU指令执行完,对于上面的三条指令来说,假设count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的2,而是1

在这里插入图片描述

一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性

3、有序性

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,例如程序中:a = 6; b = 7;,编译器优化后可能变成b = 7; a = 6;,重排序一般分为以下三种:

在这里插入图片描述

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测

1)、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性

在这里插入图片描述

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑

2)、重排序对多线程的影响

int a, b, x, y = 0;
线程1 线程2
x = a; y = b;
b = 1; a = 2;
x = 0 y = 0

如果编译器对这段代码执行重排序优化后,可能出现下列情况:

线程1 线程2
b = 1; a = 2;
x = a; y = b;
x = 2 y = 1

二、volatile

volatile是Java提供的轻量级的同步机制

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排序

1、保证可见性

class MyData {
    volatile int number = 0;

    public void addTo10() {
        this.number = 10;
    }
}

/**
 * 验证volatile的可见性
 * 假如int number = 0; number变量之前根本没有添加volatile关键字修饰,没有可见性,main线程不会停下来
 * 添加volatile可以解决可见性问题
 * volatile可以保证可见性,及时通知其他线程,主物理内存的值已经被修改,main线程会停下来
 */
public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();
        new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " come in");
                TimeUnit.SECONDS.sleep(3);
                myData.addTo10();
                System.out.println(Thread.currentThread().getName() + " updated number value:" + myData.number);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ;
        }, "Thread-1").start();
        while (myData.number == 0) {

        }
        System.out.println(Thread.currentThread().getName() + " mission is over");
    }
}

2、不保证原子性

class MyData {
    volatile int number = 0;

    public void addOne() {
        number++;
    }
}

/**
 * volatile不保证原子性
 * 运行结果:number小于20*1000
 *
 * 如何解决原子性问题
 * 1、加synchronized
 * 2、使用AtomicInteger(CAS)
 */
public class VolatileDemo {

    public static void main(String[] args) {
        MyData myData = new MyData();
        for (int i = 0; i < 20; ++i) {
            new Thread(() -> {
                for (int j = 0; j < 1000; ++j) {
                    myData.addOne();
                }
            }, String.valueOf(i)).start();
        }
        //2个线程:main线程+GC线程
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + " number value:" + myData.number);
    }
}

3、禁止指令重排序

一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例getInstance()的方法中,首先判断instance是否为空,如果为空,则锁定Singleton.class并再次检查instance

public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

实际上这个getInstance()方法并不完美,我们认为的new操作应该是:

1)分配一块内存M

2)在内存M上初始化Singleton对象

3)然后M的地址赋值给instance变量

但实际上优化后的执行路径却是这样的:

1)分配一块内存M

2)将M的地址赋值给instance变量

3)最后在内存M上初始化Singleton对象

优化后,假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到线程B上;如果此时线程B也执行getInstance()方法,那么线程B会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果这个时候访问instance的成员变量就可能触发空指针异常

使用volatile关键字修饰instance变量解决上述问题:

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;
    }
}

Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。而锁的出现主要是针对原子性问题

三、synchronized

1、synchronized的三种应用方式

synchronized关键字最主要有以下3种应用方式:

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁

  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁

2、可重入性

当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,如果是重入锁,请求将会成功,在Java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性

3、synchronized底层语义原理

Lock同步锁是基于Java实现的,而synchronized是基于底层操作系统的Mutex Lock实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销

JVM基于进入和退出管程(Monitor)对象来实现方法同步(隐式同步)和代码块同步(显式同步),但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令来实现的,而方法同步是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED标志来隐式实现的

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁

synchronized的对象锁(重量级锁),锁标识位为10,其中指针指向的是monitor对象(也称管程或监视器锁)的起始地址。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。在HotSpot虚拟机中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有两个队列,_WaitSet_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList集合,当线程获取到对象的monitor后进入_owner区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)

在这里插入图片描述

在这里插入图片描述

4、偏向锁、轻量级锁、重量级锁

1)、Java对象头

在JDK1.6的JVM中,对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充。其中Java对象头由Mark Word、指向类的指针以及数组长度三部分组成。Mark Word记录了对象和锁有关的信息。Mark Word在64位JVM中的长度是64bit,存储结构如下图所示:

在这里插入图片描述

锁升级功能主要依赖于Mark Word中的锁标志位和释放偏向锁标志位,synchronized同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁

2)、偏向锁

在这里插入图片描述

偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些情况下,大部分时间是同一个线程竞争锁资源

偏向锁的作用就是,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的Mark Word中去判断一下是否有偏向锁指向它的ID,无需再进入monitor去竞争对象了。当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是01,是否偏向锁的标志位设置为1,并且记录抢到锁的线程ID,表示进入偏向锁状态

1)偏向锁的撤销

一旦出现其它线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点(在这个时间点上没有正在执行的字节码),暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之则被其它线程抢占

2)关闭偏向锁

偏向锁在Java6和Java7里是默认启用的,但是它在应用程序启动几秒钟后才激活,如果必要刻意使用JVM参数来关闭延迟:-XX:BiasedLockingStartUpDelay=0或直接关闭偏向锁:-XX:-UseBiasedLocking=false或设置使用重量级锁-XX:+UseHeavyMonitors

2)、轻量级锁

在这里插入图片描述

当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头Mark Word中的线程ID不是自己的线程ID,就会进行CAS操作获取锁,如果获取成功,直接替换Mark Word中的线程ID为自己的ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁

1)轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁

2)轻量级锁解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进入新一轮的争锁之战

轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争

3)、自旋锁与重量级锁

在这里插入图片描述

JVM提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失

从JDK1.7开始,自旋锁默认启用,自旋次数由JVM设置决定,不建议设置的重试次数过多,因为CAS重试操作意味着长时间地占用CPU。自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会进入monitor,之后会被阻塞在_WaitSet队列中

在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于CAS重试状态,占用CPU资源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。在高负载、高并发的场景下,可以通过设置JVM参数来关闭自旋锁,优化系统性能:

-XX:-UseSpinning //参数关闭自旋锁优化(默认打开) 
-XX:PreBlockSpin //参数修改默认的自旋次数。JDK1.7后,去掉此参数,由JVM控制

4)、偏向锁、轻量级锁、重量级锁优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应时间,同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行速度较长

四、happens-before

1、happens-before的7个规则

1)、程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构

2)、管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序

3)、volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序

4)、线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作

5)、线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行

6)、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生

7)、对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

2、happens-before的1个特性:传递性

A happens-before B,B happens-before C,可以推出A happens-before C

3、案例

对一个volatile域的写,happens-before于任意后续对这个volatile域的读

public class VolatileFeaturesExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;// 1
        flag = true;// 2
    }

    public void reader() {
        if (flag) {// 3
            int i = a;// 4
            ......
        }
    }
}

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before规则,这个过程建立的happens-before关系可以分为3类

1)根据程序次序规则,1 happens-before 2;3 happens-before 4

2)根据volatile规则,2 happens-before 3

3)根据happens-before的传递性规则,1 happens-before 4

在这里插入图片描述

A线程写入一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见

发布了177 篇原创文章 · 获赞 407 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/qq_40378034/article/details/103741342