1.前言
Java内存区域也叫运行时数据区域,要记得把Java内存模型(JMM区分开来)。
根据线程是否共享可以把运行时数据区如上图所分。
- 线程共享
- 堆内存
- 方法区
- 线程私有
- 栈内存
- 本地方法栈
- 虚拟机栈
- 程序计数器
- 栈内存
接下来,将逐个介绍每个内容。额外说一句,线程私有的三部分内容(程序计数器、虚拟机栈、本地方法栈)的生命周期都跟线程相同,也就是说随着线程的创建而初始化,伴随着线程的死亡而死亡。
2.Java内存区域
程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程执行的字节码(.class)的行号指示器。
字节码解释器工作的时候就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
它是程序控制流的指示器,分支、判断、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器完成。
如果正在执行的是Java方法,那么程序计数器指向正在执行的字节码地址;如果正在执行的是Native方法(本地方法),那么计数器的值应该为空。本地方法是Java内部用C++实现的方法。
程序计数器是唯一一个在Java虚拟机规范中没有规定任何OOM(OutOfMemoryError)情况的区域。
Java虚拟机栈
虚拟机栈是Java方法执行的线程内存模型,每个方法执行的时候都会创建一个栈帧,栈帧中存放了局部变量表、操作数栈、动态连接、方法出口等信息。每个方法被调用直至执行完毕,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表
局部变量表存放了编译器可知的各种Java虚拟机基本数据类型(byte、boolean、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他的与此对象相关的位置)和returnAddress类型(指向了一条字节码指令地址)。
局部变量表中的存储空间以局部变量槽位单位,64位长度的long和double类型的数据会占用两个槽,其余的数据类型只占用一个槽。局部变量表所需要的空间(槽的个数)在编译期间就会完成分配,换句话说,局部变量表中的槽的个数在编译期间就会被确定下来,整个运行期间不会改变局部变量表的大小。而一个变量槽的大小是32kb还是64kb完全由虚拟机的具体实现决定。
这么说太抽象了,我们举个例子,动动手看一下字节码会稍微不那么抽象。
public class HelloWorld {
public static void main(String[] args) {
String str = "Hello World!";
System.out.println(str);
}
}
这段代码很简单。字节码的内容说到底其实就是字节流,硬看也能看,但是不太方便。我们可以使用javap
命令查看字节码的内容,这样会更易懂些。这里只放了部分字节码,现在只是提一下。
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: ldc #2 // String Hello World!
2: astore_1
3: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
6: aload_1
7: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: return
LineNumberTable:
line 7: 0
line 8: 3
line 9: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 args [Ljava/lang/String;
3 8 1 str Ljava/lang/String;
}
第五行就注明了操作数栈(stack)的深度以及局部变量表(locals)的槽个数。虽然如此,但是你不可以理解为操作数栈的深度也是在编译期间就已经确定了的。字符串在编译期间就会被放入常量池,ldc
是将常量池中的Hello World!
加载到操作数栈中,astore_1
是将变量从操作数栈中存储到局部变量表的1号槽位。你可能会好奇为什么明明只有一个变量,但是这里为什么局部变量表的深度为2,这是因为0号槽位被用来存储this指针,this指针指向当前对象,所以实际使用了两个槽位。
操作数栈
操作数栈也可以称之为表达式栈(Expression Stack),在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)。某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈,比如:执行复制、交换、求和等操作。操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
在上述字节码中的第六行aload_1
就是将1号局部变量槽中的值加载到操作数栈,然后将执行打印操作。
异常
在《Java虚拟机规范》中,对虚拟机栈规定了两类异常。
StackOverflowError
:线程请求的栈深度大于虚拟机所允许的深度,则会抛出该异常OutOfMemoryError
:如果Java虚拟机栈容量可以动态扩展(Java默认的虚拟机HotSpot不支持栈容量动态扩展),当栈扩容时无法申请到足够的内存时则会抛出该异常。
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。
Java堆
对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC 堆”。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。
这里“几乎”是因为即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以所有的Java对象实例都在栈内存上分配也渐渐的不是那么绝对了。
配置堆内存
Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的。
-Xmx
:Java Heap最大值,默认值为物理内存的1/4,最佳设值应该视物理内存大小及计算机内其他内存开销而定;-Xms
:Java Heap初始值,Server端JVM最好将-Xms和-Xmx设为相同值,开发测试机JVM可以保留默认值;
异常
- OutOfMemoryError:如果Java堆内存中没有足够内存完成实例的分配,并且堆也无法扩展时,Java虚拟机会抛出该异常。
方法区
方法区与Java堆一样,是各个线程共享的内存区域。它用于存储已被虚拟机加载的类型信息、常量(使用final关键字修饰)、静态变量(使用static关键字修饰)、即时编译器编译后的代码缓存等数据。
在这里就不得不提到,元空间和永久代的概念。方法区实际上是属于《Java虚拟机规范》中的一个逻辑部分。而永久代和元空间都是HotSpot虚拟机对方法区的实际实现,有点类似接口和实现类的关系。
永久代和元空间
在JDK1.6
的HotSpot中,对方法区的实现为永久代,永久代使用了堆内存的一部分作为实现,此时字符串常量池、静态变量都存放在永久代中
到了JDK1.7
的HotSpot,将原本存在于永久代中的字符串常量池、静态变量移出到堆内存中。
再到了JDK1.8的HotSpot中,对永久代的实现变为元空间,元空间不再使用堆的内存,而是使用本地内存,即操作系统的内存,但是静态变量和字符串常量池仍然存在于堆内存中。
之所以这么做,是因为使用堆内存的一部分作为永久代来实现方法区并不是一个好的决定,这种设计导致了Java应用更容易遇到堆内存内存溢出的问题。(永久代可以使用-XX: MaxPermSize来设定上限,即使不设置也有默认大小)
配置元空间
-XX:MetaspaceSize=N
:设置 Metaspace 的初始(和最小大小)-XX:MaxMetaspaceSize=N
:设置 Metaspace 的最大大小
异常
- OutOfMemoryError:如果方法区无法满足新的内存分配需求时,将抛出该异常。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。
Class文件中除了有类的版本、字 段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量 一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String
类的intern()
方法。
异常
- OutOfMemoryError:既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存 时会抛出该异常。
3.关于StringTable的拓展知识
intern()方法
上面提到了String的intern()
,而intern()
方法的作用是主动将字符串对象的值放入StringTable
中
-
如果StringTable中没有该字符串对象,则将该字符串对象放入
StringTable
,返回StringTable
中的该值(地址)- 如果是JDK1.6及之前的版本,并不是直接将该字符串对象放入
StringTable
而是将创建一个副本,将该副本放入StringTable
中
- 如果是JDK1.6及之前的版本,并不是直接将该字符串对象放入
-
如果有该字符串对象,则直接返回该字符串对象的地址
创建字符串的两种方式
-
String str = “string”
- 这种方式首先会查看StringTable中是否已经存在我们要创建的字符串,如果已经存在,就直接将该变量指向常量池中的字符串;如果不存在就会创建该字符串对象,同时将该字符串放入StringTable中
-
String str = new String(“string”)
- 这种方式将会重新开辟内存,将该字符串对象放入堆内存中,不管StringTable中是否已经存在该字符串。同时如果StringTable中没有该字符串,会将该字符串放入StringTable中。
注意:不管以哪种方式创建字符串,如果StringTable中没有该字符串,就会将其加入其中。只不过第一种方式会查看StringTable中是否有该字符串,如果没有就创建,有的话则直接引用。
字符串拼接的两种方式
-
String str = “a” + “b”
- 该拼接方式会在编译期进行优化,如果字符串
"ab"
已经存在于StringTable中,那么会将该字符串对象引用StringTable中的值
- 该拼接方式会在编译期进行优化,如果字符串
-
String str = new String(“a”) + new String(“b”)
- 该拼接方式会在运行期进行优化,如果对字节码进行反编译,会发现其实这种底层拼接方式是使用了StringBuilder进行拼接,最后调用StringBuilder的
toString
方法,效果等价于new String()
,所以以该种拼接方式产生的字符串对象并不会存在于StringTable中,而是在堆内存中
- 该拼接方式会在运行期进行优化,如果对字节码进行反编译,会发现其实这种底层拼接方式是使用了StringBuilder进行拼接,最后调用StringBuilder的
参考:《深入理解Java虚拟机》