Java程序员自我修养——内存模型

目录

一、JRE/JDK/JVM是什么关系?

二、运行时数据区的组成

三、Java对象访问定位

四、工作内存和主内存


一、JRE/JDK/JVM是什么关系?

JRE(JavaRuntimeEnvironment,Java运行环境),也就是Java平台。所有的Java 程序都要在JRE下才能运行。程序开发者不需要关心底层JRE的实现,不同的操作系统会有对应的不同的JRE,不同厂商提供的JRE也会有很大差异,对此Sun公司只提供了开发规范而未做实现细节要求。

JDK(Java Development Kit)是程序开发者用来开发、调试java程序用的java工具类库,提供了日常开发中通用的基础设施的实现。在JDK的安装过程中会自带JRE,所以在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。

JVM(JavaVirtualMachine,Java虚拟机)是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。 JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

二、运行时数据区的组成

根据 JVM 规范,JVM在执行JAVA程序的过程中会把它所管理的内存分为虚拟机栈、本地方法栈、程序计数器(PC寄存器)、堆、方法区五个部分,这五个部分称为JAVA运行时的数据区,如下图所示:

各区域跟线程的关系如下:

1.程序计数器

      又称作为PC寄存器,在汇编语言中,是指CPU中的寄存器,它保存的是程序当前执行的指令的地址,当CPU需要执行指令时,根据程序计数器中保存的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。JVM中的程序计数器并不是物理概念上的CPU寄存器,但是功能相同,即保存当前需要执行的指令的地址,其存储的数据所占空间的大小不会随程序的执行而发生改变,注意如果线程执行的是native方法,则程序计数器中的值是undefined。

    因为一个CPU的内核同一时刻只能执行一条线程中的指令,所以多线程并发执行时CPU是通过线程调度在多个线程间来回切换执行。为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。所以,程序计数器是每个线程所私有的。

2.Java栈(虚拟机栈)

     Java栈是Java方法执行的内存模型,随线程创建和销毁,线程的栈内存大小通过参数-Xss 指定,jdk5以后默认为1M。因为每个线程都对应一个独立的虚拟机栈,所以栈大小直接影响所能创建的线程数量,应该根据应用的实际运行情况调整。Java栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、动态链接、方法返回地址(Return Address)等。详情参考:

     https://blog.csdn.net/ychenfeng/article/details/77247807

    重点关注StackOverflowError堆栈溢出错误产生的过程,以下面的代码为例说明:

public class TestDemo {

    private int index = 1;

    public void method() {
        index++;
        //如果不注释掉,则不会抛出异常
        /*
        if(index>10000){
           return;
        }
        */
        method();
    }

    @Test
    public void testStackOverflowError() {
        try {
            method();
        } catch (StackOverflowError e) {
            System.out.println("程序所需要的栈大小 > 允许最大的栈大小,执行深度: " + index);
            e.printStackTrace();
        }
    }
}

  抛出异常过程详解:

执行testStackOverflowError方法会创建一个线程,同时创建一个与该线程关联的虚拟机栈(栈内存)

第一次调用method()时,会创建一个栈帧并压栈,执行index++会在该栈帧的操作数栈中写入数据,接着递归调用methed()方法,又会创建一个栈帧并压栈,因为method()方法没有返回逻辑会一直不断的递归调用method()方法,就不断的创建新的栈帧并压栈,从而导致实际栈内存不断扩大。当栈内存超过系统配置的栈内存,就会出现java.lang.StackOverflowError异常。

如果不注掉if代码,当条件被触发时,该次method()方法调用完成,该次调用对应的栈帧出栈,如果有返回值则写入下一个栈帧的操作数帧,然后调用结果往上不断返回,对应的栈帧不断出栈,直到方法执行完成。

3.本地方法栈
  本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方法栈的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

4、方法区

      方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将永久代移除了。
5.Java 堆

     在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,因此Java 堆是垃圾收集器管理的主要区域,又叫做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。Java对象的内存布局参考:https://www.jianshu.com/p/91e398d5d17c

     Java8中堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。从JDK8开始永久代(PermGen)被元空间(Metaspace)代替用于实现方法区,两者最大的区别是元空间并不在虚拟机中,而是使用本地内存,因此,默认情况下,元空间的大小仅受本地内存限制。所有新创建的对象都将在新生代Eden区域中分配内存,如果年轻代的数据在一次或多次GC后存活下来,那么将被转移到老年代,这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

      默认情况下,老年代占三分之二的堆空间,年轻代占三分之一的堆空间, eden区占8/10 的年轻代空间,survivor from区占1/10 的年轻代空间,survivor to占1/10 的年轻代空间。通过命令java -XX:+PrintFlagsFinal -version查看所有默认的jvm参数。


 

     堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大。Sun Hotspot为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的。但如果对象过大的话则仍然是直接使用堆空间分配,因此通常多个小的对象比大的对象分配起来更加高效。

三、Java对象访问定位

    通过new关键字创建一个新对象如Object obj = new Object()时,obj并不是对象本身,而是对该对象的引用,即java基础数据类型之一的reference类型。JVM规范并未规定如何根据reference类型访问对应的对象,主流的实现方式有两种:

1、句柄

如果使用句柄访问的话,那么java堆中将会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自的具体地址信息,如下图:

2、指针

   Java对象实例数据中保存对对象类型数据的指针,reference中存储的直接就是对象地址。 

       这两种对象的访问方式各有优势,使用句柄访问方式的最大好处就是reference中存放的是稳定的句柄地址,在对象的移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问方式的最大好处是速度快,它节省了一次指针定位的时间开销。目前Java默认使用的HotSpot虚拟机采用的便是是第二种方式进行对象访问的。

四、工作内存和主内存

     主内存是指所有线程共享的内存,包括Java堆和方法区,工作内存指的是每个线程执行任务时独享的内存,包括Java栈,本地方法栈,程序计数器以及CPU执行该线程指令时用到的高速缓存。当线程执行任务时会将用到的各种变量和即将调用的方法的动态链接从主内存复制一份到工作内存中,注意如果用到的是某个对象,则复制的是该对象的引用,具体执行计算时通过该引用将该对象某个属性或者方法的动态链接复制到工作内存中,计算完毕再由工作内存写入到主内存中。java内存模型定义了8种操作来完成主内存与工作内存之间的交互,这8种操作每一种都是原子操作,具体如下:

     lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
     read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
     load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
     use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;
     assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
     store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
     write(写入):作用于主内存,它把store传送值放到主内存中的变量中

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

Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:

1、不允许read和load、store和write操作之一单独出现(即不允许一个变量从主存读取了但是工作内存不接受,或者从工作内存发起会写了但是主存不接受的情况),以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
2、不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
3、不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
4、一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
5、一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
6、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
7、如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
8、对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。

猜你喜欢

转载自blog.csdn.net/qq_31865983/article/details/88090600