JMM之Synchronized&Volatile

内存可见性

  • 可见性:如果一个线程对共享变量值的修改,能够及时的被其他线程看到,那么这个共享变量就是可见的
  • 共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量

JAVA内存模型 JMM(Java Memory Model)

JMM描述了java程序中各种变量(就是指线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节

  • 主内存:整个JVM管理的内存
  • 工作内存:独属于每个线程的内存,保存该线程用到的所有变量的副本(即主内存中该变量的一个copy)


    2143704-3805b1d90f39cd80.png
    Snip20180320_1.png
  • 线程只能与自己的工作内存打交道,对共享变量的所有操作不能在主内存中直接读写,必须在自己的工作内存中进行操作,注意,是read and write
  • 如果希望与主内存交互,那么必须先操作本身的工作内存中变量,然后通过工作内存与主内存的交互达到目的
  • 再强调一点,线程只能与自己的工作内存交互,不能访问其他线程的工作内存。
  • 如果需要在工作内存中传递变量值,需要通过主内存作为桥梁处理。

共享变量内存可见性的实现原理

如果线程1修改后的共享变量A想被线程2看到,那么需要经历以下步骤:

  1. 将工作内存1中修改过的A最新值 刷新到主内存中
  2. 将主内存中被修改过的A最新值更新到工作内存2中

如果在任何一个步骤中出现问题,都会导致数据在不同的内存区域存在不同的值,也就是所谓的线程不安全。

java语言层面的实现可见性的方式:

  • synchronized
  • volatile

jdk1.5之后引入的concurrent下面的包属于另一种实现方式

可见性保证前提:

  1. 线程修改后的共享变量能及时的刷新到主内存中
  2. 其他线程能够及时的把共享变量-最新值从主内存更新到自己的工作内存中

synchronized

特性:

  • 原子性---—即同步,保证同一时间只有一个线程可以访问锁内代码
  • 可见性
JMM关于synchronized的两条规定
  • 线程解锁前,必须把共享变量的最新值刷新到主内存中
  • 线程加锁时,将清空工作内存中存储的共享变量的值,从而使用共享变量时,必须从主内存中重新读取最新的值。(注意:解锁和加锁,是指同一把锁)

满足以上两条规定,也就意味着解锁前对共享变量的值的更新可以在,下次加锁时对其他线程是可见的。

但是即使没加入synchronized修饰,主内存和工作内存之间的数据更新也不一定不会发生,因为cpu缓存的刷新是非常快的,只有在高并发的情况下才会出现线程不安全的情况。

线程执行互斥代码的过程如下:
  1. 获取互斥锁
  2. 清空工作内存,把本线程所有工作内存中的共享变量都清除
  3. 从主内存copy变量的最新副本到工作内存
  4. 开始执行代码
  5. 如果有更新,则把更改后的共享变量值刷新到主内存中
  6. 释放互斥锁

指令重排序

代码书写的顺序和实际执行的顺序可能不同,指令重排序是编译器JIT或者处理器JVM为了提高程序性能而做的优化,主要有三种:

扫描二维码关注公众号,回复: 5185523 查看本文章
  1. 编译器优化的重排序,由编译器优化,主要在单线程环境下,在保证结果正确性之前对代码的执行顺序进行调整
  2. 指令级并行重排,处理器级别优化,cpu支持指令级并行技术,多核
  3. 内存系统的重排序,主要是处理器对读写缓存进行的优化,也就是上面说的主内存、工作内存之类的操作

as-if-serial

指无论如何重排序,程序执行的结果应该与代码顺序执行的结果一致。

java编译器、运行时(RunTime,JVM)和处理器都会保证java在单线程下遵循as-if-serial语义

所以指令重排序不会导致单线程下出现内存可见性问题。

但是在多线程中,如果代码交错执行,那么就有可能出现可见性问题

只有数据依赖相关才会禁止重排序,逻辑上控制,比如if语句和if中的逻辑,也可能会出现重排序

导致共享变量在线程间不可见的原因

  1. 线程的交叉执行
  2. 重排序+线程的交叉执行
  3. 共享变量更新后的值没有在工作内存与主内存之间即使更新
synchronized的解决方案
  1. —>原子性,synchronized修饰的代码在一定时间内只能由一个线程持有,线程释放锁之后才会被其他线程占用
  2. —>原子性,重排序只能在单线程内部排,不会出现3.1—> 4.2 这种情况的重排
  3. —> synchronized 可见性保证,释放锁的时候会刷新到主内存

代码:

package com.alan.alanstatemachine;  
import org.junit.Test;
import java.lang.Thread;

  /**
     * 用于学习synchronized使用方法
  */
public class SynchronizedDemo {

// 首先定义三个变量, 都是共享变量
boolean ready = false;
int number = 2;
int result = 0;

/**
 * 写方法,更改共享变量的值
 */
public void write() {
    ready = true; // 步骤1.1
    number = 4;   // 步骤1.2
    }

/**
 * 读方法,打印共享变量的值
 */
public void read() {
    if (ready) {             // 步骤2.1
        result = number * 3;  // 步骤2.2
    }

    System.out.println("current result = " + result);
}

/**
 * sync写 方法,更改共享变量的值
 */
public synchronized void writeWithSync() {
    ready = true; // 步骤3.1
    number = 6;   // 步骤3.2
}

/**
 * sync读 方法,打印共享变量的值
 */
public synchronized void readWithSynv() {
    if (ready) {             // 步骤4.1
        result = number * 3;  // 步骤4.2
    }

    System.out.println("sync current result = " + result);
}

// 创建一个内部线程类,用于启动多个线程测试内存可见性
class ReadAndWriteThread extends Thread {

    boolean flag = false;

    ReadAndWriteThread(boolean outFlag) {
        this.flag = outFlag;
    }

    /**
     * If this thread was constructed using a separate
     * <code>Runnable</code> run object, then that
     * <code>Runnable</code> object's <code>run</code> method is called;
     * otherwise, this method does nothing and returns.
     * <p>
     * Subclasses of <code>Thread</code> should override this method.
     *
     * @see #start()
     * @see #stop()
     */
    @Override
    public void run() {
        if (flag) {
            write();
            writeWithSync();
        } else {
            read();
            readWithSynv();
        }
    }
}

@Test
public void test() {

    SynchronizedDemo demo = new SynchronizedDemo();

    // 传入true,应该是写操作,修改工作内存中的共享变量值
    demo.new ReadAndWriteThread(true).start();

    // 传入false,读操作,看是否拿到了最新的修改后 的ready及number、result数据
    demo.new ReadAndWriteThread(false).start();

    // 保证可见性的情况
    // 如果执行顺序是 1.1 --> 2.1 --> 2.2 --> 1.2 那么结果是6
    // 如果执行顺序是 1.1 --> 1.2 --> 2.1 --> 2.2 那么结果是12
    // 如果重排序1,执行顺序是1.2 --> 2.1 --> 2.2 --> 1.1,那么结果是0
 }
}

Volatile保证内存可见性

  • Volatile可以保证共享变量的可见性
  • 但是并不能保证原子性
  • volatile通过内存屏障和禁止指令重排序来实现内存可见性
    • 线程在对volatile修饰的变量进行写操作之后,处理器会在写操作后加入一个store的屏障指令,会把处理器写缓冲区的缓存强制刷新到主内存中去,所以主内存中的变量值就是最新值。(??但是,不是写在工作内存中的吗?跟处理器缓存有啥关系?难道还是通过处理器缓存来实现的刷新同步么?)—注意,工作内存只是一个逻辑概念,并不真实存在,它是由写寄存器+写缓冲区实现的
    • store的屏障指令还能防止处理器将volatile修饰变量之前的操作重排序到volatile修饰变量之后
    • 对volatile变量进行读操作时,会在读操作前加入一条load屏障指令,也会强制缓冲区缓存失效,从而读到主内存中变量的最新值。同时load屏障指令也有禁止指令重排序的效果,不是禁止所有的,只是禁止volatile变量之前和之后的操作位置互换

所以线程对volatile变量的操作步骤就如下:

写:
  1. 改变工作内存中共享变量副本的值
  2. 将最新值及时刷新到主内存中去
读:
  1. 失效工作内存中变量,强制从主内存中读取最新的变量值存入工作内存中,作为副本存在
  2. 使用时从工作内存中读取。

JMM中定义了8条指令来完成主内存和工作内存的数据同步操作

// TODO

Volatile不能保证原子性

private int number = 1;
number++;

其中number++并不是原子操作,它是以下三个分解操作的简写:

1. get number value from number_var
2. number_value + 1 to a temp var
3. set new number value to number_var

对于synchronized,

     synchronized(this){
        number++;
     }

由于synchronized的语言特性,number++语句此时是一个原子操作

而对于volatile:

private volatile int number = 1;
number++;

无法保证原子性

示例代码:

 /**
   * volatile为什么不能保证对共享变量的原子性操作
  */
public class VolatileDemo {

private volatile int number = 0;

/**
 * increase
 */
public void increase() {
    // 可见性是肯定的
    // 为了更直观看到无法保证原子性,休眠下
    try {
        Thread.sleep(20);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    number++;
}

/**
 * get number
 *
 * @return current number value
 */
public int getNumber() {
    return number;
}

@Test
public void test() {

    // 刚开始线程数
    System.out.println("start thread count =" + Thread.activeCount() );
    int startThreadCount = Thread.activeCount();
  
    // 启动线程增加volatile变量
    for (int i = 0; i < 500; i++) {
        new Thread(() -> increase()).start();
    }
    // 如果还有子线程未执行完,则主线程通过yield()让出cpu资源
    while (Thread.activeCount() > startThreadCount) {
        System.out.println("current sub thread count=" + Thread.activeCount());
        Thread.yield();
    }

    System.out.println("current number value =" + getNumber());
}
}

可以看下,基本上getNumber的值最后都是小于500的。问题就出在number++上

因为volatile无法保证number++的三个分解操作的原子性,所以可能同时有三个线程过来操作这三个操作,整个过程就会串掉。

假设某一时间点 number = 5:

  • 线程A获取到cpu资源,获取到number的值,然后释放掉cpu
  • 线程B获取到cpu资源,获取到nubmer的值
  • 线程B number +1
  • 线程B 将number写入到工作内存,由于volatile修饰了number变量,那么在主内存及线程B的工作内存中,number = 6
  • 但是线程A的工作内存中,number = 5,因为读取的时候确实是从主内存中读取的最新值,那么在后续操作的时候不需要再去主内存中再读一次
  • 此时如果线程A再获取到cpu资源,执行+1操作,并写入工作内存中,那么此时number = 6,再写入到主内存中,也还是6
  • 那么虽然对number进行了两次++操作,但是实际上在主内存中,只加了一个1
保证number原子性的解决方案有:
  1. 使用synchronized关键字修饰number++代码
  2. 使用ReentrantLock(java.util.concurrent.locks)
  3. 使用AtomicInteger (java.util.concurrent.atomic)

修改后的代码:

import org.junit.Test;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**

- volatile为什么不能保证对共享变量的原子性操作
  */
  public class VolatileDemo {

private volatile int volatileNumber = 0;

// 使用synchronized,不需要再使用volatile来保证可见性
private int synchronizedNumber = 0;

// 使用reentrantLock
private int lockNumber = 0;

private Lock lock = new ReentrantLock();

/**

- increase
  */
  public void volatileIncrease() {
  // 可见性是肯定的
  // 为了更直观看到无法保证原子性,休眠下
  try {
      Thread.sleep(20);
  } catch (InterruptedException e) {
      e.printStackTrace();
  }
  this.volatileNumber++;
  }

public void synchronizedIncrease() {
    // 可见性是肯定的
    // 为了更直观看到无法保证原子性,休眠下
    try {
        Thread.sleep(5);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    // 为什么不在方法定义上加synchronized呢?
    // 其实也可以,但是这样锁的粒度比较大,休眠也被锁住了,无法释放资源,等待时间就会比较久
    // 所以在代码块的基础上加synchronized关键字
    synchronized (this) {
        this.synchronizedNumber++;
    }
}

public void lockIncrease() {
    // 可见性是肯定的
    // 为了更直观看到无法保证原子性,休眠下
    try {
        Thread.sleep(5);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    // 使用reentrantLock
    lock.lock();
    try {
        this.lockNumber++;
    } finally {
        lock.unlock();
    }
}

/**

- get number
  *
- @return current number value
  */
  public int getVolatileNumber() {
  return this.volatileNumber;
  }

public int getSynchronizedNumber() {
    return this.synchronizedNumber;
}

public int getLockNumber() {
    return this.lockNumber;
}

@Test
public void test() {
    // 刚开始线程数目
    System.out.println("start thread count =" + Thread.activeCount() );
    int startThreadCount = Thread.activeCount();

    // 启动线程增加volatile变量
    for (int i = 0; i < 500; i++) {
        new Thread(() -> volatileIncrease()).start(); // volatile 肯定不准
        new Thread(() -> synchronizedIncrease()).start(); // synchronized  为啥这个也不准呢?
        new Thread(() -> lockIncrease()).start(); // Lock  为啥你也不准呢?
    }

    // 如果还有子线程未执行完,则主线程通过yield()让出cpu资源
    while (Thread.activeCount() > startThreadCount) {
        Thread.yield();
    }

    System.out.println("current volatile number value =" + getVolatileNumber());
    System.out.println("current synchronized number value =" + getSynchronizedNumber());
    System.out.println("current lock number value =" + getLockNumber());
}
}

volatile使用的场景

要在多线程中安全的使用volatile变量,需要满足以下两个条件:

  1. 对变量的写操作不依赖其当前值
    • 比如 count++,count=count+1这种,就不满足
    • 而对于boolean类型、温度变化场景,就满足
  2. 该变量不能包含在其他变量的不变式中
    • 比如有两个volatile变量,low 和 up,如果存在 low < up 的这种不变式比较,则不满足

Synchronized VS volatile

  • volatile不需要加锁,不会阻塞线程,所以相对synchronized 更轻量级,性能更高
  • 从内存可见性角度讲,对volatile变量的读操作,相当于synchronized的加锁,即清空工作内存,从主内存中更新最新值到工作内存中
  • 而对volatile的写操作,相当于synchronized的解锁,将数据及时刷新到主内存中
  • synchronized同时保证可见性及原子性,而volatile只保证可见性

补充:

java中long和double都是64位的,而对这两种类型对象的操作可能并不是原子操作,因为jmm允许jvm对没有添加volatile修饰的64位对象操作分解为两次32位读写操作来操作,所以加上volatile可以解决这种问题。不过大多数情况下,多数jvm都已经实现了原子性,所以不需要特殊操作。

另一种保证内存可见性的操作,是使用final修饰,这个后续再细看。

猜你喜欢

转载自blog.csdn.net/weixin_33742618/article/details/87161855