JAVA内存模型和线程

来源:互联网 发布:网络兼职日结免费入职 编辑:程序博客网 时间:2024/05/16 15:44

正式讲解Java虚拟机并发相关之前,先了解下物理计算机的并发问题,两者有相似之处,物理机对并发的处理方案也对虚拟机的实现有参考意义。

1.1 硬件效率与一致性

计算机并发执行若干计算任务其中一个重要的复杂性是绝大多数的运算任务都不可能是只靠处理器完成的,处理器还要与内存交互,比如读取运算数据,存储运算结果。计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:

将运算需要使用的数据复制到缓存中,让运算能快速进行,当计算结束后再从缓存同步到内存中,这样处理器就无须等待缓慢的内存的读写了。


基于高速缓存的存储交互解决了处理器与内存的速度矛盾,但也带入了新的问题:缓存一致性。多处理器系统中,每个处理器都有自己的高度缓存,他们又共享同一个主内存,这将导致缓存之间和主内存之间数据数据不一致。为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议来读写数据比如MSI,MESI协议等。

JAVA的内存模型就可以理解为特定操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象。

除了增加高速缓存之外,为了尽可能利用处理器运算能力,处理器可能会对输入的代码进行乱序执行(Out-Of-Order Execution)优化,然后对结果重组,保证与顺序执行的一致性,但并不保证程序各个语句计算先后顺序与输入代码中的顺序一致,因此,如果存在一个任务依赖于另一个任务的中间结果,那么其顺序性不能靠代码的先后顺序来保证。与处理器乱序执行优化类似,JAVA虚拟机即时编译器中也有类似指令重排序(Instruction Reorder)优化。


1.2 JAVA内存模型

Java虚拟机通过定义内存模型来屏蔽掉各种硬件和操作系统的内存访问差异。


1.2.1 主内存和工作内存

JAVA内存模型主要目标是定义程序中各变量的访问规则,即虚拟机将变量存储到内存和从内存取出变量这种底层细节。此处的变量指实例字段,静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的,不会被共享。

JAVA内存模型规定了所有的变量都存储在主内存,每个线程有自己的工作内存(与处理器高速Cache类比),线程的工作内存中保存了该线程中使用变量的主内存副本拷贝,线程对变量的所有操作都是在工作内存中进行,不能直接读写主内存中的变量,线程间变量值的传递通过主内存来完成。

PS:当然不会把主内存中的整个对象拷贝,比如只会拷贝工作内容访问到对象的一个属性域等。



1.2.2 内存间交互操作

JAVA内存模型,定义了八种操作来完成主内存和工作内存间的读写。虚拟机必须保证这些操作都是原子的(对于double,long型的变量有例外):

  1. lock(锁定):作用于主内存中的变量,把变量标识为一个线程独占
  2. unlock(解锁):作用于主内存中的对象,把锁定状态的变量释放出来,释放后才可以被其他线程锁定
  3. read(读取):作用于主内存中的变量,把变量的值从主内存传输到工作内存,以便随后的load动作使用
  4. load(载入):作用于工作内存中的变量,把read操作的从主内存中得到的变量值放入工作内存的变量副本中
  5. use(使用):作用于工作内存中的变量,把工作内存中的变量值传递给执行引擎
  6. assign(赋值):作用于工作内存中的变量,把执行引擎的值赋给工作内存的变量
  7. store(存储):作用于工作内存中的变量,把工作内存中的变量值传送到主内存,以便随后的write操作使用
  8. write(写入):作用于主内存中的变量,把store操作的变量放入主内存变量中


变量从主内存复制到工作内存,就要顺序地执行read,load操作;从工作内存同步到主内存就要顺序执行store,write操作。Java内存模型主要求上述两个操作必须按顺序执行。但不要求连续执行,即read与load,store与write间可插入其他的指令。如访问主存中的a,b可能的顺序为read a、read b、load b、load a。

除此之外,Java内存模型还要求如下的规则:

  1. 不允许read和load、store和write操作单独出现。即不能从主存读取但工作内存不接受,或者工作内存回写但是主存不接受
  2. 不允许一个线程丢弃它最近的assign操作。即工作内存改变了必须同步到主存上
  3. 不允许一个线程无原因地(没有assgin)把数据从工作内存同步到主内存
  4. 一个新变量只能在主内存中“诞生”,不允许在工作内存中直接使用未被初始化的变量
  5. 一个变量在同一时刻只允许一条线程对其lock操作,但lock可以被同一个线程重复执行多次,多次lock后,只有执行了相同次数的unlock操作,变量才会被解锁
  6. 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load和assign操作初始化变量的值
  7. 如果变量没有被lock,则不允许被unlock,也不允许去unlock一个被其他线程锁定住的变量
  8. 对一个变量执行unlock前,必须把变量同步到主内存中,执行store,write

这些繁琐的步骤有一个等效判断原则--先行发生原则


1.2.3 volatile类型变量的特殊规则

volatile关键字是Java虚拟机提供的最轻量级的同步机制。

volatile定义的变量具有两种特性:

第一保证此变量对所有线程的可见性,可见性指一个线程修改了这个变量的值,其他线程可以立即得知。普通变量需要同步到主存后其他线程才能得到修改后的值。

volatile的变量在工作内存中不存在不一致问题(工作内存,volatile是存在不一致情况,但由于每次使用之前需要刷新,执行引擎看不到不一致情况,所以认为不存在一致性问题),但是Java运算并不是原子的,所以volatile的变量在并发环境中依然是不安全的。如下例所示:

public class VolatileTest {public static volatile int race = 0;public static void increase(){race++;}private static final int THREAD_COUNT = 20;public static void main(String[] args){Thread[] threads = new Thread[THREAD_COUNT];for(int i=0;i<THREAD_COUNT;i++){threads[i] = new Thread(new Runnable(){public void run() {for(int i=0;i<1000;i++){increase();}}});threads[i].start();}while(Thread.activeCount()>1){Thread.yield();}System.out.println(race);                            }}

上例演示20个线程操作自增运算1000次,按理解结果应该是20000,但是运算后却是17871并且每次都不一样,所以上述代码遇到了并发安全性问题。

问题在race++自增运算中,Javap反编译发现该语句有4个字节码指令

getstatic     //race取到栈顶iconst_1      //1入栈iadd          putstatic     //计算结果赋值

其中getstatic指令将race值取到栈顶时,volatile关键字保证了race值的正确性,但是iconst_1,iadd指令执行时,其他线程可能已经把race的值增加了,所以当putstatic将结果同步到主存中时,数据就不正确了。

所以volatile只能保证可见性,在不满足以下两种规则下,我们仍然要通过加锁(synchronize或者java.util.concurrent中的原子类)来保证原子性。

  1. 运算结果不依赖变量的当前值,或者只有一个线程修改变量的值
  2. 变量不需要与其他的状态变量共同参与不变约束

比如如下的场景就适合用volatile关键字控制并发

public class VolatileTest2 {volatile boolean shutdownRequested;public void shutdown(){shutdownRequested = true;}public void doWork(){while(!shutdownRequested){//do}}}


使用volatile变量的第二个语义是禁止指令的重排优化,普通变量仅仅会保证该方法执行中所有的依赖赋值结果的地方都能取到正确的结果,却不保证变量赋值的顺序和程序代码顺序一致。在一个线程中是不会有什么问题的,这也就是Java内存模型中描述的所谓“线程内表现为串行语义”。

但是如果不同的线程就可能会出问题。如下例:


如果initialized没有volatile修饰,就有可能出现指令重排的问题,导致initialized=true会提前执行,那么线程B就会出问题。


1.2.4 对long和double型变量的特殊规则

Java内存模型要求上节提到的八个操作对32位的数据是原子性的,但是64位的数据有较宽松的要求,可以按两个32位的数据处理。

但是现在商用的JVM都实现了long和double型数据的原子操作,所以一般不需要把long和double特殊声明为volatile。



1.2.5 原子性,可见性,有序性

原子性:原子性指操作不可分割,Java内存模型保证原子性的变量操作,对于开发者来说基本类型的读写是具有原子性的(long和double在商用虚拟机上也可以认为是原子性的)。

如果应用场景需要更大范围的原子性,Java内存模型提供了lock和unlock操作,反应到代码上即synchronized关键字,因此在synchronized块中的操作也是原子性的。


可见性:可见性指当一个线程修改了共享变量的值,其他线程可以立即得知。除了volatile之外,Java还有两个关键字可以实现可见性,synchronized和final。同步块的可见性是由”对一个变量执行unlock前,必须把变量同步到主存“这个规则获得的。final修饰的字段在构造器中一旦初始化完成,其他线程就能看见final字段的值。


有序性:Java程序的有序性用一句话解释就是:如果在本线程中观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。前半句是指”线程内表现为串行的语义“,后半句是指”指令的重排优化“。

Java提供了volatile和synchronized关键字保证线程之间操作的有序性,volatile关键字禁止了指令的重排,synchronized则规定了”一个变量在同一时刻值允许一个线程对其lock操作“。这两种情况都决定了持有同一个锁的两个同步块都只能串行的进入。


synchronized关键字是这3中并发特征的万金油,但是也会引起滥用,从而导致性能的影响。后面会继续讲到如何进行锁优化。


1.2.6 先行发生原则

Java内存模型中所有的有序性不仅仅靠volatile和synchronized关键字完成,Java语言有一个”先行发生“原则:

  1. 程序次序规则:在一个线程内,按照程序代码书写的顺序,书写在前先行发生于书写在后的操作。
  2. 管程锁定规则:一个unlock操作先行发生于后面对同一锁的lock操作。
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。
  4. 线程启动规则:Thread对象的start方法先行发生于线程中所有动作。
  5. 线程终止规则:线程中所有操作都先行于线程终止检测。
  6. 线程中断规则:线程interrupt方法调用先行发生于被中断线程的代码检测到中断事件的发生。
  7. 对象终结规则:一个对象的初始化完成先行于它的finalize方法。
  8. 传递性:操作A先行发生于B,操作B先行发生于C,则A先行发生于C。


1.2.7 Java线程状态切换


  1. sleep,wait区别:通过上图看到sleep结束后立即进入可运行状态,而wait后唤醒的线程还要竞争对象锁;所以sleep不会释放对象锁(如果有synchronized块话),而wait会释放对象锁。
  2. 两个队列:等待池和锁池,Java中每个对象都会有一个等待池和一个锁池,进入等待池的对象被唤醒后进入锁池竞争资源。
  3. join:主线程等待调用join方法的子线程执行结束后再继续执行。分布式处理框架fork/join
  4. yield:让出CPU资源给其他的任务,由yeild后直接进入可运行状态可以看到,yeild不会释放对象锁(如果有synchronized块话);





0 0
原创粉丝点击