Java有许多特点,如线程并发,类加载,垃圾回收,语法糖,丰富的官方类库,易懂的面向对象设计,平台兼容等等。对于内存来说,从宏观上,主要分为静态和动态两部分,静态的是内存的空间结构,动态的是内存的分配和回收。本篇文章,就来总结一下Java中的内存结构。
1. 语言间的内存差异
相对于C来说,在对内存的处理方式上,Java做了两点不同的决策。
1.2 内存的空间结构
对于C来说,程序员会以系统的视角去看内存——一块连续的字节数组。一个C程序最起码是一个进程,它会直接映射到操作系统对进程的空间分配中,如程序块和数据,用户栈,运行堆,共享库以及用户无法访问的内核虚拟内存。一个简单的HelloWorld.c
程序,它的代码会存放到程序块区域,静态变量会存放到数据区,当调用函数时,会把函数地址和变量压入用户栈中,当使用malloc分配空间时,数据会存放在运行堆中。
对于Java来说,VM会对内存再进行一次抽象和封装。它把内存分为线程独有的程序计数器、虚拟机栈、本地方法栈和线程共享的堆以及方法区(其实方法区也是堆)。当程序new一个对象后,对象的实例会被存放到堆中,对象的信息和方法以及静态变量会被存放到方法区中。
总结来说,C语言中,程序员可以访问的内存单位是字节数组;而Java语言中,程序员可以访问的内存单位是抽象出来的对象。因为寄存器可以访问的内存单位也是字节数组,这说明使用C语言的程序员可以直接访问底层,进行更多操作,这也是C语言程序员鄙视Java程序员的一个重要原因之一。但是访问的更多不仅仅意味着权利,也以为着责任,因为没有对象级的抽象,导致了C程序尤其是大型程序会莫名奇妙的抛出缓冲区溢出,数组越界,乱码等等难以找到根源的问题。
1.2 内存的分配回收
对于C来说,程序员需要主动去申请和释放内存。而对于Java来说,因为有了VM的存在,GC会自动回收不可达的对象。但是同时GC线程在回收垃圾的时候会造成STW(Stop the world),拖慢程序的性能。而且,因为对象交给了VM来负责清除,那么对象的清除时间其实是不可预测的。相对来说,C程序员可以完全操控内存的分配和回收时间,可以完全掌握内存的信息,这点是Java程序员做不到的。但是,如果C程序中忘记free
内存空间,也将会造成不可估量的损失。
从内存处理的方式来看,C程序有着不容置疑的对内存完全掌控的能力,而对Java程序来说,因为VM做了一层抽象后,Java程序把内存的操作交给了VM来处理,变成了间接访问。同时,因为有了VM,Java程序可以随心所欲的编写代码而不用担心出现很大的BUG,这导致了Java语言极易上手,Java程序员良莠不齐,一直被行业诟病。
2. Java中的内存结构
Java的内存结构几乎是面试必问的一个问题,所以这里再拉出来回顾一下。
以下内存结构针对JDK8
2.1 基本概念
- 虚拟机栈:线程私有,每个方法非native方法对应一个栈帧,栈帧中包括局部变量表(以slot为基本单位,存储方法参数和内部变量),操作数栈(存储计算参数和结果),动态链接等
- 本地方法栈:线程私有,负责native方法
- 程序计数器:线程私有,记录某个线程的字节码执行位置
- 堆:线程共有,用来存放实例,是Jvm最大的一块区域,也是GC管理的主要区域。在JDK7及以前,分为新生代*( Eden 8、From Survivor 1、To Survivor 1 )*,老年代。新生代与老年代是1:2
- 方法区:存储类的结构信息,除了运行时常量池,其他的都在MetaSpace中放着
- 运行时常量池,运行时将常量池的数据放入这里,同时包括字符串常量池,这个实际是在堆中放着,可以动态变化(如
String#intern()
) - 常量池,存放编译期生成的字面量(文本字符串、八种基本类型的值、被声明为final的常量)以及符号引用(类和方法的全限定名、字段的名称和描述符、方法的名称和描述符),大小在运行期前已知。以类为单位
- 方法字节码,存放的是各个方法的字节码(依赖操作数栈和局部变量表,由JVM解释执行)
- 运行时常量池,运行时将常量池的数据放入这里,同时包括字符串常量池,这个实际是在堆中放着,可以动态变化(如
2.2 程序演示
对于一个类来说,它主要分为两部分,分别是属性和方法。这两部分和我们计算机基础中的数据结构和算法是一一对应的。对于C程序来说,数据结构存放在堆中,算法存放在代码块中。下面,我们通过一个程序来说明Java程序的这两部分分别存放在哪里?
分配情况不考虑TLAB,JIT,逃逸分析等,同时也不考虑Eden和Survivor这些区域
1 | class MemoryStructTest { |
当该程序被编译、类加载、初始化和运行时,这个程序在内存中是什么样子的呢?
MemoryStructTest
这个类的信息在类加载之后会被存储到方法区中。其中,包括Field,Method,常量池,static变量,类名等- 类加载的最后一步初始化会去初始化static变量
HELLO_WORLD
,此时会去对String类进行类加载,String
类会被按照刚才的方式类加载进入方法区,然后HELLO_WORLD
这个字符串的引用会被字符串常量池中,而它真正被存在了堆中。而helloworld
这个本地变量则是存在了虚拟机栈中了。 - 在运行之后,之前常量池的部分内容会被拷贝到运行时常量池中,同时,该类的然后VM开始逐行解释执行方法区
main
方法的字节码 - 当调用到第五行的时候,会对
System.out
这个类类加载到方法区,之后调用方法区中的静态方法,打印helloWorld
这个本地变量
可以得到下图:
有一点需要注意的是,对于String和基本类型,如果不主动new,它们会在常量池中,如果主动new,则会产生在堆中
3. 具体类型的存储格式
3.1 父类的存储格式
这个是我大二时候画的一个内存图,现在看来,还是有些东西的,再次放上来:
1 | class A { |
3.2 接口的存储格式
1 | interface I { |
PS:上面的 “堆·类” 应该改为方法区
3.3 内部类的存储格式
内部类通过语法糖会被解析为一个普通的类,然后和上边是一样的
3.4 数组的存储格式
在Java中,数组也是一个对象,这样的抽象,极大程度上保证了对数组操作的安全性,不会产生C程序中的数组越界时内存覆盖的问题。
一个包含数组,接口,类以及继承的程序:
声明:本程序完全是为了演示接口,类等的内存逻辑模型的demo
另:此程序有点冗余,但确实可以说明继承关系,而且内存逻辑模型是用手画的,只画了一些重要部分,没有上面程序画的详细
1 | package chapter06; |
3.5 基本数据类型的存储格式
对于基本数据类型来说,如果是局部变量,则直接回按照字节规范分配到栈上。如果是类的全局变量,则随着实例的初始化分配到堆上。
如果类实例的某个属性是另外个实例,那么会有指针指向另外个实例。而如果属性是基本类型的话,则没有指向,直接分配这个这么大字节的空间
附基本类型的字节:byte 1 字节,char 2 字节,short 2 字节,int 4 字节,float 4 字节, long 8 字节, double 8 字节,boolean 不确定
4. 后记
其实这些东西我在去年的这个时候都已经知道了,但是没有一个贯通的概念,今天一总结,才有种豁然开朗的感觉。
但是,了解的越多,才发现在不懂的越多,加油吧
无论高级语言提供多么优美的数据模型,编译成机器代码之后,它只是简单地将内存理解为一个很大的,按照字节(8bits)寻址的数组。而机器访问内存,则都是通过地址来访问的
32位机器和64位机器有什么不同呢?就是说的地址总线,32位机器表明程序的地址可以占用4个字节,能够访问的地址有4GB。而64位则表明程序的地址可以占8个字节,能够访问2^64byte