这里讨论的是Java运行时数据区,不是JMM内存模型
Java的内存结构大致分为5个部分:
更详细的图:
图中蓝色区域是线程私有(除了堆和方法区),黄色区域是线程共有的(堆和方法区)
1.PC(程序计数器)
当执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为“取指令”。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。
2.JVM stack(JVM栈)
每一个方法对应一个栈帧,每个栈帧包含
(1)局部变量表(Local Variable):一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量,以变量槽(Variable Slot)为最小单位,一个变量槽可以存放一个32位以内的数据类型,这些数据类型有boolean、byte、char、short、int、float、reference和returnAddress这8种类型,reference表示对一个对象实例的引用。对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间,Java中明确的64位的数据类型只有long和double两种。由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,所以不管读还是写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
类变量存在两个阶段赋值的过程:第一次是在准备阶段,赋予系统初始值(通常说的0值,也就是类型默认值)第二次是在初始化阶段,赋予程序员定义的初始值。因此类变量没有赋值也没有关系,仍然会有系统初始值。但是局部变量不像类的变量一样存在"准备阶段",如果没有赋初始值,是完全无法使用的,变异的时候就会报错
槽的第一个位置是方法的入参,比如main方法的args入参(非static方法第一个局部变量是this)
bipush 把后面的值当成byte类型,然后扩展为int类型的值压栈,后面跟int类型的数(大于128的时候使用sipush表示当成short类型)
istore: store int into local variable 把操作数栈的栈顶元素弹出存储到局部变量表的对应位置
iload: load into from local variable 从局部变量表拿变量值压栈
bipush 8 将8压栈
istore_1 将栈顶元素取出赋值给局部变量表的槽位1
iload_1 从局部变量表的槽位1的值拿出来压栈
iinc 1 by 1把局部变量表位置位1的数加1
istore_1 把栈顶元素弹出赋值给局部变量表的1位置的变量
所以这个面试题的结果是8,执行过程如下:
1. 往栈里放一个值为8的int类型的数(栈中为8)
2.弹出栈中的栈顶元素赋值给局部变量表里的槽位1的变量(栈为空)
3.从局部变量表拿到对应位置的变量值值压栈(将8压栈,栈顶元素为8)
4.局部变量表位置为1的变量加1(局部变量表1位置的数变为9)
5.弹出栈顶元素赋值给局部变量表里的位置位1的变量(栈中的数字是8,把局部变量表的9覆盖)
如果把代码改为i = ++i; ,则bytecode如下:
这个过程我们可以看到是这样的:
1. 往栈里放一个值为8的int类型的数(栈中为8)
2.弹出栈中的栈顶元素赋值给局部变量表里的槽位1的变量(栈为空)
3.局部变量表位置为1的变量加1(局部变量表1位置的数变为9)
4.从局部变量表拿到对应位置的变量值值压栈(将9压栈,栈顶元素为9)
5.弹出栈顶元素赋值给局部变量表里的位置位1的变量(栈中的数字是9)
iadd是从栈顶拿出两个int型的数计算然后结果放回栈里(栈顶)
(2)操作数栈(operand ):操作数做加减乘除的过程中分配的临时的内存空间,用完就没了。栈的最大深度在编译的时候就被写入到Code属性的max_stacks数据项之中了。它的每一个元素都可以是包括long和double在内的任意Java数据类型,32位的数据类型所占的栈容量是1,64位的为2
(3)动态链接(Dynamic Linking):把方法的符号如math.compute转换为方法执行的内存入口地址,比如A调用B方法,执行A方法的时候需要去常量池中找B方法
(4)方法出口(返回值地址,return address),方法(如compute)调用完的时候把调用它的方法(如main方法)的返回地址记录下来放在compute的方法出口,可以继续回到main方法继续往下执行。比如A方法调用了B方法,B方法执行完之后应该放哪里以及B执行完之后应该回到哪个地址继续执行
方法有两种方式退出:正常调用完成的退出或者抛出异常异常调用完成的退出。不管是哪种方式退出,在方法退出之前都必须返回到最初方法被调用时的位置,程序才能继续执行
方法退出的过程实际上等同于把当前的栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入到调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等
3. Heap(堆)
对象
4.Method Area(方法区)
这是一个逻辑上的概念
具体实现在jdk1.8之是永久代,1.8及以后是元空间,存储:常量+静态变量+类信息
在JVM启动时被创建,并且物理内存可以不连续
大小可以固定也可以是动态扩展的(如果不加限制则最大可以用完物理内存,一般我们会加-XX:MetaspaceSize以及MaxMetaspaceSize做限制(一般设置为一样的))
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类会出现OOM错误。
会随着JVM的关闭而释放这一区域的内存
5.本地方法栈
native关键字修饰的叫本地方法,底层实现不是java,是c或者c++,这些方法放到本地方法栈
JVM为什么要区分栈和堆?
(1)从软件设计的角度来看,栈代表了处理逻辑,而堆代表了数据,这样分离使得处理逻辑更为清晰。这种隔离、模块化的思想在软件设计的方方面面都有体现。
(2)堆与栈的分离,使得堆中的内容可以被多个栈共享。这种共享有很多好处,一方面提供了一种有效的数据交互方式(如内存共享),另一方面,节省了内存空间。
(3)栈因为运行时的需要(如保存系统运行的上下文),需要进行址段的划分。由于栈只能向上增长,因此会限制住栈存储内容的能力。而堆不同,堆的大小可以根据需要动态增长。因此,堆与栈的分离,使得动态增长成为可能,相应栈中只需要记录堆中的一个地址即可。
(4)堆和栈的完美结合就是面向对象的一个实例。其实,面向对象的程序与以前结构化的程序在执行上没有任何区别,但是面向对象的引入使得对待问题的思考方式发生了改变,是更接近于自然的思考方式。当把对象拆开会发现,对象的属性其实就是数据,存放在堆中,而对象的方法就是处理逻辑,存放在栈中。我们编写对象的时候,其实即编写了数据结构,也编写了处理数据的逻辑。
总结:栈主要用来执行程序,堆主要用来存放对象,为栈提供数据存储服务。也正是因为堆与栈分离的思想才使得JVM的垃圾回收成为可能。
JVM GC只回收堆区和方法区内的对象,不回收虚拟机栈内的数据,栈内数据在超出作用域后会被JVM自动释放掉。
因为JVM GC回收堆区的对象,所以先了解学习一下堆内存的结构图:
堆内存分为年轻代(Young Generation)、老年代(Old Generation),年轻代和老年代所占空间比例默认是1:2。年轻代又分为Eden和Survivor区,Survivor区由FormSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。From和To主要是为了解决内存碎片化。
为什么使用元空间代替永久代?
我认为有三个方面的原因:
(1)在1.7 版本里面,永久代内存是有上限的,虽然我们可以通过参数来设置,但是
JVM 加载的class 总数、大小是很难确定的。所以很容易出现OOM 问题。但是元空间是存储在本地内存里面,内存上限比较大,可以很好的避免这个问题。
(2)永久代的对象是通过FullGC 进行垃圾收集,也就是和老年代同时实现垃圾收集。
替换成元空间以后,简化了Full GC。可以在不进行暂停的情况下并发地释放类
数据,同时也提升了GC 的性能
(3)Oracle 要合并Hotspot 和JRockit 的代码,而JRockit 没有永久代。