[java]-[内存模型]
引入
一种语言的内存模型决定了该语言的运行机制,运行效率以及各种高级特性的性能(比如多线程并发)。因此要想编写出高效率的程度,达到对某种语言的精通,必须对该语言的内存模型有比较深入的认识。
讲解
运行时内存结构
java运行时内存结构也就是我们常说的jvm基本结构(《深入理解Java虚拟机(第二版)),java程序在运行时,需要在内存中分配程序需要的内存空间,为了提高程序的运行效率,对数据具体的存储空间进行了不同空间的划分,并且每一片区域都有特定的处理数据方式和内存管理机制,一般来说一共分为5个区域:
- 方法区(Method Area):java虚拟机加载的类信息、常量、静态变量
- 堆区(Heap Area):new出来的具体对象
- java虚拟机栈(VM Stack):局部变量;线程产生是创建,方法运行时生成栈帧
- 本地方法栈(Native Method Stack)
- 程序计数器(PCR)
多线程内存模型
java线程之间通信采用共享内存模型,共享内存模型规定了一个线程对共享变量的写入对另外一个线程何时可见,并定义了线程与主内存之间的抽象关系:
线程之间的共享变量存储在主内存(Main memory)中每个线程都有一个私有的本地内存(Local memory)或者叫做工作内存,本地内存中存储了该线程使用到的变量到主内存的拷贝,线程对共享变量的所有操作都在本地内存中进行,不能够直接对主内存进行读写,也就是说线程之间变量值的传递都必须进过主内存完成。
基于上述模型的机制,当多个线程操作同一个共享对象时,如果没有合理的使用volatile和synchronization关键字,一个线程对共享对象的更新有可能导致其他线程对该共享变量修改的不可见,从而发生程序错误。
在深入分析内存模型之前,介绍几个基础概念:
内存屏障(Memory Barrier):
是一个特定的CPU指令,可以起到如下作用
1 保证特定操作的执行顺序
2 影响某些数据(或者是某条指令的执行结果)的内存可见性
happens-before:
如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系
1 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中任意的后续操作
2 对一个锁的解锁操作happens-before于随后对这个锁的加锁规则
3 volatile域规则:对于一个volatile域的写操作,happens-before与任意线程后续对于这个volatile域的读
4 传递性规则:如果A happens-before B,B happens-before C,则A happens-before C
指令重排序
1 编译器优化重排序:编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序
2 指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
3 内存系统的重排序:处理器使用缓存和读写缓冲区,使得加载和存储操作看上去可能是在乱序执行
数据依赖性
1 如果两个操作同时访问一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性
2 编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会对其进行重排序
as-if-serial
1 不管怎么重排序,单线程下执行结果不能够改变
2 编译器、runtime和处理器都必须遵循as-if-serial语义
并发编程基本问题概念
1 原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
2 可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
3 有序性:即程序执行的顺序按照代码的先后顺序执行。
volatile关键字
当一个共享变量被volatile修饰之后,具备如下含义:
– 保证了不同变量对该变量修改的可变性,即一个线程修改了这个变量的值,这个新值对其他变量来说是立即可见的
– 禁止内存重排序
示例:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
上述代码是很典型的一段代码,很多人在中断线程时都会采用这种方法,但是这段代码不能够保证完全正确执行,正确的做法是对stop变量采用volatile关键字进行修饰
//线程1
volatile boolean stop = false;
增加volatile关键字之后
– 线程2在对stop变量进行赋值后,会强制将该变量的值写入主存。
– 由于线程2 对stop变量进行了写操作,线程1 stop变量在local memory中的副本值无效,在读取该值时会从主存中读取。
volatile不能够保证对变量操作的原子性。
示例:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
synchronized关键字
相比于volatile关键字用来解决内存可见性的问题,synchronized关键字主要用来解决控制执行的问题,该关键字会组织其他线程获取当前对象的监控锁,这样就使得当前对象中被synchronized关键字保护的代码块无法被其它线程访问,也就无法并发执行。更重要的是,synchronized还会创建一个内存屏障,内存屏障指令保证了所有CPU操作结果都会直接刷到主存中,从而保证了操作的内存可见性,同时也使得先获得这个锁的线程的所有操作,都happens-before于随后获得这个锁的线程的操作。
对于上面多线程增加inc值的例子如果采用synchronized关键字则可以保证运行结果的正确性。
public class Test {
public int inc = 0;
public synchronized void increase() {
inc++;
}
...
final关键字
对于final域,编译器和处理器要遵守两个重拍规则:
– 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
– 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
public class FinalExample {
int i; //普通变量
final int j; //final变量
static FinalExample obj;
public void FinalExample () { //构造函数
i = 1; //写普通域
j = 2; //写final域
}
public static void writer () { //写线程A执行
obj = new FinalExample ();
}
public static void reader () { //读线程B执行
FinalExample object = obj; //读对象引用
int a = object.i; //读普通域
int b = object.j; //读final域
}
}
假设一个线程A执行writer ()方法,随后(执行完FinalExample的构造函数)另一个线程B执行reader ()方法。在这种情况下,线程B可能会错误的读出还未被初始化的成员变量i值,但是会正确读出被初始化为2的final修饰的成员变量j值
应用
正确使用volatile变量的条件:
– 对变量的写操作不依赖当前值
– 该变量没有包含在具有其他变量的不变式中
– 在访问变量时不需要加锁
正确使用volatile的模式:
1. 状态标记–使用一个boolean类型的volatile变量,用于指示发生了某一个重要事件,比如完成初始化或者请求停机
volatile boolean shutdownRequested;
...
public void shutdown() { shutdownRequested = true; }
public void doWork() {
while (!shutdownRequested) {
//
}
}
2.一次性安全发布(one-time safe publication)–在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在,同时也可以解决double-check的问题。
public class BackgroundFloobleLoader {
public volatile Flooble theFlooble;
public void initInBackground() {
// do lots of stuff
theFlooble = new Flooble(); // this is the only write to theFlooble
}
}
public class SomeOtherClass {
public void doWork() {
while (true) {
// do some stuff...
// use the Flooble, but only if it is ready
if (floobleLoader.theFlooble != null)
doSomething(floobleLoader.theFlooble);
}
}
}
如果 theFlooble 引用不是 volatile 类型,doWork() 中的代码在解除对 theFlooble 的引用时,将会得到一个不完全构造的 Flooble。
该模式的一个必要条件是:被发布的对象必须是线程安全的,或者是有效的不可变对象(有效不可变意味着对象的状态在发布之后永远不会被修改)。volatile 类型的引用可以确保对象的发布形式的可见性,但是如果对象的状态在发布后将发生更改,那么就需要额外的同步。
扩展
与C++语言内存模型的比较
与java gc各区域对应关系
一般说perm区域就是指方法区; young区,old区均是指堆区
java7、java8内存模型演变
从jdk1.7就开始了对permGen space的移除工作,存储在permGen space中的部分数据就已经转移到java heap或者native heap中,比如符号应用(symbols)就转移到了native heap中,字面量(interned strings)转移到了java heap,但是permGen space仍然存在
jdk1.8彻底将permGen space移除,取而代之的是Metaspace,中文也可以叫元空间
元空间(Metaspace)与持久空间的比较
PermGen space一般使用jvm内存,而Metaspace是使用本地物理内存
在使用Metaspace模型的java8中,给每个类加载器分配一个内存块的列表,只进行线性的分配,块的大小取决于类加载器的类型,sun/反射/代理对应的类加载器的块会小一些
不会单独回收某个类,如果GC发现某个类加载器不在存活,会把相关的空间整个回收掉,这样可以减少碎片,并节省GC扫描和压缩的时间
发散
java内存模型与我们常说的java运行时分为的5个区域这两者的关系可以说是抽象和具体的关系,java内存模型从比较抽象的方式定义了java程序运行(包括多线程)内存共享以及线程间通信的相关要求,而java运行时的5个区则具体实现了java内存模型。
从某种意义上来理解,比较类似于j2ee开发中servlet的规范和tomcat 容器的关系,servlet规范从抽象的维度定义了基于java技术的web组件请求/应答模型的一系列要求,而tomcat作为servlet容器则是对servlet规范的一种实现
java内存模型<----->servlet规范
jvm<----->servlet容器
java运行时5区<----->tomcat