(三)Java内存模型

本文详细解析了Java内存模型中的内存可见性原理,以及编译器和CPU重排序机制。重点讲解了volatile关键字在保证变量可见性和避免并发问题中的作用,同时讨论了单例模式中的内存一致性问题和解决策略,以及内存屏障在控制指令执行顺序的重要性。
摘要由CSDN通过智能技术生成

Java内存模型(JMM)

内存可见性

当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A看到的变量值在B获得锁后同样可以由B看到。

换句话说,当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。

JMM规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见。如下图所示。

重排序

编译器重排序

对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。

CPU指令重排序

在指令级别,让没有依赖关系的多条指令并行。

CPU内存重排序

CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。(造成内存可见性的原因之一)

volatile关键字

volatile关键字修饰成员变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

注意:volatile变量只保证可见性,而不保证原子性

仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量

一般来说,满足以下条件可以应用volatile变量

1.对变量的写入操作不依赖变量的当前值(例如不是递增操作),或者能确保只有单个线程更新变量的值。

2.该变量不会与其他状态变量一起纳入不变性条件中。

3.在访问变量时不需要加锁。

下面的代码说明了volatile变量的一种用法 。

public class JUCTest {
	private static volatile boolean flag;

    public static void main(String[] args) throws InterruptedException {
	    Thread thread =new Thread(()->{
	    	try{
			    while (!flag){
				    System.out.println("what are you doing");
			    }
		    }catch (Exception e){
	    		e.printStackTrace();
		    }
	    });
	    thread.setDaemon(false);
	    thread.start();
        //主线程睡眠5秒
	    TimeUnit.SECONDS.sleep(5);
	    flag=true;
        System.out.println("end......................");
    }
}

主内存与工作内存

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

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

关于主内存与工作内存之间具体的交互,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)。

lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read与load之间、store与write之间是可插入其他指令的。

规则:

不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。

不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。

一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。

对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

偏序关系:Happens-Before 

要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排序。

Happens-Before的规则包括:

程序顺序规则。如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行。监视器锁规则。在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。

volatile变量规则。对volatile变量的写入操作必须在对该变量的读操作之前执行。

线程启动规则。在线程上对Thread.Start的调用必须在该线程中执行任何操作之前执行。

线程结束规则。线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false。

中断规则。当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException,或者调用isInterrupted和interrupted)。

终结器规则。对象的构造函数必须在启动该对象的终结器之前执行完成。

传递性。如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。

目前在类库中提供的其他Happens-Before排序包括:

1.将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行。

2.在CountDownLatch上的倒数操作将在线程从闭锁上的await方法中返回之前执行。

3.释放Semaphore许可的操作将在从该Semaphore上获得一个许可之前执行。

4.Future表示的任务的所有操作将在从Future.get中返回之前执行。

5.向Executor提交一个Runnable或Callable的操作将在任务开始执行之前执行。

6.一个线程到达CyclicBarrier或Exchanger的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果CyclicBarrier使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。

 不安全的单例模式

当缺少Happens-Before关系时,就可能出现重排序问题,在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。同样,在发布一个引用时也需要写入一个变量,即新对象的引用。如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序(从使用该对象的线程的角度来看)。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的是无效值,即一个被部分构造的对象。

public class UnsafeLazyInitialization {
  private static Resource resource;

  public static Resource getInstance() {
    if (resource == null) {
      // 不安全的发布return resource;
      resource = new Resource();
    }
  }
}

上面代码中假设线程A是第一个调用getInstance的线程。它将看到resource为null,并且初始化一个新的Resource,然后将resource设置为执行这个新实例。当线程B随后调用getInstance,它可能看到变量resource的值为非空,因此使用这个已经构造好的Resource。最初这看不出任何问题,但线程A写入resource的操作与线程B读取resource的操作之间不存在Happens-Before关系。在发布对象时存在数据竞争问题,因此B并不一定能看到Resource的正确状态。

当新分配一个Resource时,Resource的构造函数将把新实例中的各个域由默认值(由Object构造函数写入的)修改为它们的初始值。由于在两个线程中都没有使用同步,因此线程B看到的线程A中的操作顺序,可能与线程A执行这些操作时的顺序并不相同。因此,即使线程A初始化Resource实例之后再将resource设置为指向它,线程B仍可能看到对变量resource的写入操作将在对Resource各个域的写入操作之前发生。因此,线程B就可能看到一个被部分构造的Resource实例,该实例可能处于无效状态,并在随后该实例的状态可能出现无法预料的变化。

除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。

有双锁检查(DCL)问题的单例模式

public class UnsafeLazyInitialization {
	private static Resource resource;

	public static Resource getInstance() {
		if (resource == null) {
			synchronized (UnsafeLazyInitialization.class){
				if (resource == null){
					resource = new Resource();
				}
			}
		}
		return resource;
	}
}

上述的instance=new Instance()代码有问题:其底层会分为三个操作:

(1)分配一块内存。

(2)在内存上初始化成员变量。

(3)把instance引用指向内存。 

在这三个操作中,操作(2)和操作(3)可能重排序颠倒,即先把instance指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。在执行完(3)时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问里面的成员变量,就可能报空指针异常。解决办法就是为instance变量加上volatile修饰(禁止重排序)。

改进型的单例模式

在初始器中采用了特殊的方式来处理静态域(或者在静态初始化代码块中初始化的值),并提供了额外的线程安全性保证。静态初始化器是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用之前。JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。

//同步关键字的方式
public class SafeLazyInitialization {
  private static Resource resource;

  public static synchronized Resource getInstance() {
    if (resource == null) {
      resource = new Resource();
      return resource;
    }
  }
}
//静态初始化的方式
public class EagerInitialization{
	private static Resource resource=new Resource();
	public static Resource getResource(){
		return resource;
	}
}

 最优的单例模式

使用了一个专门的类来初始化Resource。JVM将推迟ResourceHolder的初始化操作,直到开始使用这个类时才初始化,并且由于通过一个静态初始化来初始化Resource,因此不需要额外的同步。当任何一个线程第一次调用getResource时,都会使ResourceHolder被加载和被初始化,此时静态初始化器将执行Resource的初始化操作。

这种方法结合了静态初始化和懒加载的优点。

//静态内部类的方式
public class ResourceFactory{
	private static class ResourceHolder{
		public static Resource resource = new Resource();
	}
	public static Resource getResource(){
		return ResourceHolder.resource;
	}
}

内存屏障(memory barrier)

为了禁止编译器重排序和CPU 重排序,在编译器和CPU层面都有对应的指令,这就是内存屏障。

编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在。

CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。

Java中的内存屏障

volatile变量读操作的性能消耗与普通变量几乎没有什么差别,写操作会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:

只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;

只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。

线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值)。

只有当线程T对变量V执行的前一个动作是assign时,线程T才能对变量V执行store动作;

只有当线程T对变量V执行的后一个动作是store时,线程T才能对变量V执行assign动作。

线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改)。

假定动作A是线程T对变量V实施的use或assign动作,

假定动作F是和动作A相关联的load或store动作,

假定动作P是和动作F相应的对变量V的read或write动作;

假定动作B是线程T对变量W实施的use或assign动作,

假定动作G是和动作B相关联的load或store动作,

假定动作Q是和动作G相应的对变量W的read或write动作。

如果A先于B,那么P先于Q(这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)。

final修饰的字段域

对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。当构造函数完成时,构造函数对final域的所有写入操作,以及对通过这些域可以到达的任何变量的写入操作,都将被“冻结”,并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于通过final域可到达的初始变量的写入操作,将不会与构造过程后的操作一起被重排序。

初始化安全性只能保证通过final域可达的值从构造过程完成时开始的可见性。对于通过非final域可达的值,或者在构成过程完成后可能改变的值,必须采用同步来确保可见性。

例如下面的代码是线程安全的。

public class SafeStates {
	private final Map<String, String> states;
	public SafeStates(){
		states=new HashMap<>();
		states.put("alaska","AK");
		states.put("alabama","AL");
		states.put("wyoming","WY");
	}
	
	public String getAbbreviation(String s){
		return states.get(s);
	}
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值