Java教程

01-Java内存模型

本文主要是介绍01-Java内存模型,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

I. 内存模型的基础

同步:程序中控制不同线程间操作操作顺序的机制

Java线程间采用共享内存变量的方式进行通信

由JVM可知,线程共享的内存区域为堆和方法区,而方法区存放的是类型参数和常量,不存在同步问题,因此共享内存变量主要针对的是堆内存

0. 一些相关术语

  • 内存屏障:一组处理器指令,限制内存操作的顺序

  • 缓存行:缓存中可分配的最小单位

  • 原子操作:不可中断的操作

  • 缓冲行填充:缓存内存中的操作数

  • 缓存命中:访问的操作数在缓存中存在

  • 写命中:写回的操作数在缓存中,则更新缓存

  • 写缺失:写回缓冲区失败

1. Java内存的抽象模型

JMM:Java内存模型的简称

可见性:保证某一个变量被一个线程更新后,这个更新也出现在其他线程的缓存中

image-20211031170439190

2. 指令的重排序

在程序的执行中,执行的顺序不一定是我们编写的顺序

其中编译器可能对程序做重排序,执行时的处理器也可能对程序重排序

比如

x=1;
y=2;

编译器优化后的执行顺序可能是

y=2;
x=1;

重排序会带来什么问题呢?

多线程的情况下,重排序可能会带来同步问题,因此JMM必须采取措施来防止某些语句的重排序。

JMM的处理方法是插入指定的内存屏障。

3. 内存屏障

现代处理器都采用了写缓冲区的机制,但写缓冲区里面的数据更新要刷新到内存,并被其他线程的读缓冲区读取才对其他线程可见

指令的重排序会破坏这一过程,因此需要插入内存屏障

  • Load:数据从内存装载到线程缓冲区
  • Store:数据从线程缓冲区刷新到内存
内存屏障 指令示例 说明
LoadLoad Load1; LoadLoad; Load2 Load1的装载先于Load2的装载
LoadStore Load1; LoadStore; Store2 Load1的装载先于Store2的刷新
StoreStore Store1; StoreStore; Store2 Store1的刷新先于Store2的刷新
StoreLoad Store1; StoreLoad; Load2 Store1的刷新先于Load2的装载

II. volatile内存语义

volatile的效果和对这个变量的写操作加锁效果是一样的

volatile long vi;
public void write(){vi++;}

long vi;
public void synchronized write(){vi++;}
  • 可见性:对一个volatile变量的读,总能看到最后线程的写入

  • 对于任意volatile变量的读写具有原子性

    注意,复合操作不具有原子性,比如vi++,因为vi++实际上是vi先加1再赋值

1. volatile内存语义的实现

  • 写实现:写一个volatile变量时,JMM会把缓存中的volatile共享变量值刷新到内存

  • 读实现:读一个volatile变量时,会把缓存的值置为无效,从内存中重新装载

III. 锁的内存语义

释放锁:JMM会把临界区缓存刷新到内存

获取锁:接下来临界区的读操作都会把缓存置为无效,从内存中读取

IV. final域的内存语义

1. final写的重排序

由于指令重排序,构造函数执行完成可能在对象初始化完成之前

比如,两个线程可能会像下面一样执行

class test {
    int i;
    test(){
        i = 5;
    }
}

image-20211031204323810

JMM会禁止final域写重排序到构造函数之外,即对象构造完成时final域一定初始化完成

class test {
    final int i;
    test(){
        i = 5;
    }
}

image-20211031204613699

2. final读的重排序

重排序规则:初次读对象引用和初次读该对象包含的final域,禁止重排序这两个操作

由于这两个操作具有数据依赖,大多数编译器本来也不会重排序这两个操作

如果final域是引用类型:构造函数初始化final域和把final域变量赋给其他变量不能重排序

3. 为什么final引用不能从构造函数逸出

final域的重排序可以确保:任意线程引用这个final域变量时,已经成功初始化

如果是下面这种情况:

class Test {
    final int i;
    static Test obj;
    Test(){
        i = 1;
        obj = this;	//包含final域的引用逸出了
    }
}

注意,构造函数里面obj和i的赋值操作是可以重排序的,所以可能会发生下面的情况,读取到未初始化的i:

image-20211031215318247

V. happens-before原则

JMM的设计就是依照happens-before的原则实现的

  1. 程序顺序规则:一个线程中的任意操作,先于这个线程中的任意后续操作(虽然可能会指令重排,但就结果来看,是没有影响的
  2. 监视器锁规则:对于一个锁的解锁,先于随后对这个锁的加锁
  3. volatile规则:对volatile域的写,先于之后对这个域的读
  4. 传递性:A先于B,B先于C,则A先于C
  5. start()规则:线程的start()操作早于线程的任何操作
  6. join()规则:如果线程A中执行B.join(),则B线程的执行先于join()返回后的任意操作

VI. double-check与延迟初始化

1. double-check

延迟初始化:推迟某些对象的创建,直到需要的时候才进行对象的初始化

比如,典型的单例模式中的饱汉模式:

public class FullMan{
    private static Instance instance;	//暂时不初始化
    //要用的时候才初始化
    public static Instance getInstance(){
        if(instance == null) instance = new Instance();	
        return instance;
    }
}

但是,这个例子在多线程的环境下,缺乏同步机制,可能会出现问题

比如A线程初次进入,开始创建对象;还没创建完成时B线程又进来,也开始创建对象

因此,可以通过加锁解决这个问题:

public class FullMan{
    private static Instance instance;	//暂时不初始化
    //要用的时候才初始化
    public static synchronized Instance getInstance(){
        if(instance == null) instance = new Instance();	
        return instance;
    }
}

不过synchronized会引起上下文切换,并发量高的情况下性能会很低

因此,可以使用double-check来进行延迟初始化

public class FullMan{
    private static Instance instance;	//暂时不初始化
    //要用的时候才初始化
    public static Instance getInstance(){
        if(instance == null){							//1.第一次check
            synchronized(FullMan.class){
            	if(instance == null) instance = new Instance();		//2.第二次check
        	}
        }
        return instance;
    }
}

和上面的单次check相比,多个线程访问时,只要instance != null马上就能返回,不需要排队

只有在进行初始化才需要加锁操作,大大提高了效率,不过这个double-check也是有问题的

对象初始化这个操作,其实可以分解为下面三行伪代码:

memory = allocate(); 	//1.分配内存
ctorInstance(memory);	//2.在内存块上初始化对象
instance = memory;		//3.地址赋给instance

操作2和操作3可能会被重排序,执行顺序变成1->3->2,先得到地址,再进行初始化

只要在单线程中,操作2在访问对象的域对象之前执行,这种重排序就是合法的

就单线程而言,这种重排序是可以的,但如果在多线程中呢?

image-20211031223037939

线程B访问对象时,对象可能还没有初始化!

2. 优化延迟初始化

有两种方案实现线程安全的double-check

  • 不允许2和3重排
  • 允许2和3重排,但new操作完成之前,这个重排序对其他线程不可见

2.1 基于volatile的double-check

采用的第一种方案,volatile修饰的对象初始化在多线程环境中会禁止重排序

设置要读取的变量为volatile即可

public class FullMan{
    private volatile static Instance instance;	//暂时不初始化
    //要用的时候才初始化
    public static Instance getInstance(){
        if(instance == null){							//1.第一次check
            synchronized(FullMan.class){
            	if(instance == null) instance = new Instance();		//2.第二次check
        	}
        }
        return instance;
    }
}

2.2 基于类初始化的解决方案

类初始化时,JVM会获取一个锁,同步多个线程对同一个类的初始化

public class FullMan{
  //static确保类加载时进行初始化
  private static class FullManHolder{
      public static Instance instance = new Instance();
  }
  public static Instance getInstance(){
      return FullManHolder.instance;
  }
}

原理:类加载时,Class对象会有一个初始化锁,保证同一时间只有一个线程进行Class对象的初始化

这篇关于01-Java内存模型的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!