聊聊并发安全

java内存模型

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。

Java内存模型规定了所有的变量都是存在主存中(如磁盘等物理内存),每个线程都有自己的工作线程(高速缓冲区)。线程对变量的操作必须是在工作内存中进行,而不能直接操作主存。工作内存中保存了主内存变量的拷贝副本。不同的线程无法访问其他线程的工作内存中的变量。不同线程直接值的传递必须通过主存,当工作线程完成了对变量副本的操作之后,将操作后的值存回主存中,其他的线程再通过主存获取。

内存交互操作

根据上图可知,将一个变量从主内存拷贝到工作内存,再从工作内存同步到主内存,java内存模型定义了8种操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

如果要把一个变量从主存拷贝到工作内存,必须按顺序经历read-load.把工作内存中的变量副本同步回主内存,必须顺序的执行store-write.同时JMM还规定了执行上述8种操作的时候必须遵循:

  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

原子性

原子操作就是某一操作要么全部执行,一旦执行了就不可中断,要么就不执行。对基本数据类型变量的读取和赋值就是一个原子操作。

我们先来看几个例子:

int x = 1 ;  //语句1
int y = x ;  //语句2
x++ ;        //语句3
y=y+1;       //语句4

上述4个语句只有语句1是原子操作。语句2其实包含了2个操作,首先读取x的值,然后再将x写入工作内存,虽然读取x的值和将x写入工作内存都是原子操作,但是两步合起来就不是一个原子操作了。语句3和语句4都包含了3步操作,先读取x/y的值然后对其做加1然后将新值写入工作内存,所以也是不具备原子性的。

java.util.concurrent.atomic包提供了很多高效的机器指令而不是通过锁来保证的原子性的类,例如:AtomicInteger。他利用了乐观锁的思想,使用CAS机制来保证操作的原子性。相关细节大家如果感兴趣可自行翻阅资料。

可见性

可见性是指不同线程之间的可见性,某一个线程里对变量修改的结果能被其他线程立马看见。普通的变量是无法保证可见性的,因为根据JMM的内存模型,每个线程都有一个工作内存,对变量的操作都是在各自线程的工作内存中进行的,当工作线程中完成对变量的修改之后,什么时候更新进主存是不确定的。当其他线程去读的时候,主存中可能还是未修改之前的旧值,这就导致出现了问题。

我们可以使用volatile来修改变量,被volatile修饰的变量只要一有改动马上会被更新到主存,从而保证数据的正确性。

有序性

在JMM中,为了提高程序执行的性能,允许编译器和处理器对指令进行重排序,重排序对于单线程没有影响,但是会影响到多线程并发的正确性。

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


分配空间给对象常见的单例模式的双重检查的写法,但是为啥要加volatile呢?我们来分析一下:
如果不加volatile,会出问题的在instance = new Singleton();
其实这一句代码包含了三个操作:

  1. 分配空间给对象
  2. 在空间内创建对象
  3. 将对象赋值给引用instance

上述3步操作中2是依赖于1的,所以2需要在1之后执行,但是3和2是不存在依赖的,所以执行的顺序有可能是1-3-2,如果是单线程的情况下,是不会有有问题的,你在使用instance的时候,jvm会保证你使用的instance是初始化完成的。但是在多线程的情况就无法保证了,如果获得锁的线程执行的顺序是1-3-2,当执行到3的时候,会store-write,将值写回主存,此时由于还没有执行2,instance为一个不完全的对象,不安全的对象,使用这个对象是有危险的,同时由于2还没执行,所以该线程也不会释放锁。此时其他线程进行第一次检查时,instance == null为false,这就造成了异常。
所以此处使用volatie来修饰,volatile会禁止指令的重排序,从而保证了并发的安全性。

JMM为所有程序内部动作定义了一个偏序关系,叫做happens-before。要想保证执行动作B的线程看到动作A的结果(无论A和B是否发生在同一个线程中),A和B之间就必须满足happens-before关系。

《Java并发编程实践》中对于happens-before的规则有:

  • 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
  • 监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
  • volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  • 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C

volatile

如果一个变量被volatile修饰,这个变量就有了两层语义:

  • 保证了不同线程对该变量操作的可见性
  • 禁止指令重排序

查看一个例子:

//线程1
boolean loop = true;
while(loop){
    doSomething();
}

//线程2
loop = false;

假设线程1先执行,线程2后执行。根据JMM可知,每个线程都会有自己的工作内存,当线程2在自己的工作内存中把loop重新赋值为false,还没有来得及将值更新到主存,而去干其他事,这就会导致线程1中的值一直是旧的值,所以会一直循环下去。如果loop使用volatile修饰就不一样了:

  • 使用volatile修饰之后,线程2改动后的值会立即写到主存中
  • 线程2改动之后会导致线程1中的缓存失效
  • 线程1失效之后就会重新到主存中读取新的值

volatile支持原子性吗?

首先来看一个例子:

public class Test {
    public  volatile int number = 0;
    public void increase(){
        number++ ;
    }
    public static void main(String []args){
        final Test test = new Test();
        for (int i=0;i<10;i++){
            new Thread(){
                @Override
                public void run() {
                    for(int j=0;j<10000;j++){
                        test.increase();
                    }
                }
            }.start();
        }

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(test.number);
    }
}


这段代码每次运行的结果都不同,都是一个小于100000的值。
假如某个时刻number的值是100,此时线程1先读取了number的值,然后被阻塞了。线程2读取number的值进行自增操作,由于线程1只是做了读取操作,所以不会导致线程2缓存失效。线程2发现读到的值也是100,做自增101,写入工作内存然后写入主存。然后线程1接着做自增,由于之前已经读取了变量值100,所以自增1结果也是101再写入主存。此时已经做了2次自增,但是结果还是101.
自增操作是不具备原子性的,volatile也无法保证变量操作的原子性

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器学习模型机器
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值