1. 运行时数据区概述
1.1 JVM运行时数据区规范
JVM虚拟机组成:
方法区(类信息)、堆(伊甸园、老年代、永久代:new的对象等)、虚拟机栈、程序计数器、本地方法栈
1.方法区和堆是内存共享的
2.虚拟机栈、本地方法栈、程序计数器是内存不共享的
3.方法区:类信息(1.7之前还有字符串常量池和静态变量)
方法区中存放的是类型信息、常量、静态变量、即时编译器编译后的代码缓存、域信息、方法信息等。随着JDK的发展,
方法区中存放的内容也在发生变化。并不绝对。通常情况下放的是这些内容。
4.堆():new对象、数组
5.虚拟机栈、本地方法栈为了方法执行使用的
虚拟机栈是为了Java方法使用的内存区域,后入先出
本地方法栈式为了第三方方法(c函数)使用的内存区域
6.程序计数器是一块较小的内存空间,不会出现oom(其它四个区域都可能出现)
7.运行时常量池,位于方法区
直接内存不属于jvm内存区,是属于本地内存;jvm通过直接内存,从而使用到本地内存
jdk1. 7之前,HotSpot虚拟机对于方法区的实现称之为“永久代”, Permanent Generati on 。
jdk1. 8之后,HotSpot虚拟机对于方法区的实现称之为“元空间”, Meta Space 。
方法区是Java虚拟机规范中的定义, 是一种规范, 而永久代和元空间是 HotSpot 虚拟机不同版本的两种实现。
1.2 Hotspot运行时数据区
1.3 分配JVM内存空间
分配堆的大小
–Xms(堆的初始容量)
-Xmx(堆的最大容量)
分配方法区的大小
-XX:PermSize
永久代的初始容量
-XX:MaxPermSize
永久代的最大容量
-XX:MetaspaceSize
元空间的初始大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释
放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize
最大空间,默认是没有限制的。
除了上面指定大小的选项以外,还有两个与 GC 相关的属性:
-XX:MinMetaspaceFreeRatio
在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
-XX:MaxMetaspaceFreeRatio
在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集
分配线程空间的大小
-Xss:
为jvm启动的每个线程分配的内存大小,默认JDK1.4中是256K,JDK1.5+中是1M
2. 方法区
2.1 方法区存储内容
存储已经被虚拟机加载的类信息、 常量、 静态变量、 即时编译器编译后的代码等等。
存储示意图如下,下面的图片显示的是JVM加载类的时候,方法区存储的信息:
1、 类型信息
- 类型的全限定名
- 超类的全限定名
- 直接超接口的全限定名
- 类型标志(该类是类类型还是接口类型)
- 类的访问描述符(public、private、default、abstract、final、static)
2、 类型的常量池
存放该类型所用到的常量的有序集合,包括直接常量( 如字符串、 整数、 浮点数的常量) 和对其他类型、 字段、 方法的符号引用。常量池中每一个保存的常量都有一个索引,就像数组中的字段一样。因为常量池中保存着所有类型使用到的类型、字段、方法的字符引用,所以它也是动态连接的主要对象(在动态链接中起到核心作用)。
3、 字段信息( 该类声明的所有字段)
字段修饰符(public、protect、private、default)
字段的类型
字段名称
4、 方法信息
方法信息中包含类的所有方法,每个方法包含以下信息:
- 方法修饰符
- 方法返回类型
- 方法名
- 方法参数个数、类型、顺序等
- 方法字节码
- 操作数栈和该方法在栈帧中的局部变量区大小
- 异常表
5、 类变量( 静态变量)
指该类所有对象共享的变量,即使没有任何实例对象时,也可以访问的类变量。它们与类进行绑定。
6、 指向类加载器的引用
每一个被JVM加载的类型,都保存这个类加载器的引用,类加载器动态链接时会用到。
7、 指向Class实例的引用
类加载的过程中,虚拟机会创建该类型的Class实例,方法区中必须保存对该对象的引用。通过Class.forName(String className)来查找获得该实例的引用,然后创建该类的对象。
8、 方法表
为了提高访问效率,JVM可能会对每个装载的非抽象类,都创建一个数组,数组的每个元素是实例可能调用的方法的直接引用,包括父类中继承过来的方法。这个表在抽象类或者接口中是没有的。
9、 运行时常量池(Runtime Constant Pool)
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面常量和符号引用,这部分内容被类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于Class文件常量池的另外一个特征具有动态性,可以在运行期间将新的常量放入池中(典型的如String类的intern()方法)。
3. 永久代和元空间区别
永久代和元空间存储位置和存储内容的区别:
存储位置不同,永久代物理是是堆的一部分,和新生代,老年代地址是连续的,而元空间属于本地内存;
存储内容不同,元空间存储类的元信息,[静态变量]和[常量池]等并入堆中。相当于永久代的数据被分到了堆和元空间中。
通过上面分析,大家应该大致了解了 JVM 的内存划分,也清楚了 JDK 8 中永久代向元空间的转换。不过大家应该都有一个疑问,就是为什么要做这个转换?带着这个疑问,最后给大家总结以下几点原因:
1. 字符串存在永久代中,容易出现性能问题和永久代内存溢出。
2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
3. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
4. Oracle 可能会将HotSpot 与 JRockit 合二为一。
其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。
4. 方法区异常演示
4.1 类加载导致OOM异常
案例代码
我们现在通过动态生成类来模拟方法区的内存溢出:
package com.dev.test.memory;
public class Test {}
package com.dev.test.memory;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.List;
public class PermGenOomMock{
public static void main(String[] args) {
URL url = null;
List<ClassLoader> classLoaderList = new ArrayList<ClassLoader>();
try {
url = new File("/tmp").toURI().toURL();
URL[] urls = {url};
while (true){
ClassLoader loader = new URLClassLoader(urls); //不通的classload加载同一个类,会在方法区中创建多个类对象
classLoaderList.add(loader);
loader.loadClass("com.dev.test.memory.Test");
}
}
catch (Exception e) {
e.printStackTrace();
}
}
}
JDK1. 7分析
指定的 PermGen 区的大小为 8M。
[外链图片转存中…(img-L9Zo5zkw-1695191042191)]
绝大部分 Java 程序员应该都见过 "java. lang. OutOfMemoryError: PermGen space "这个异常。这里的 “PermGen space”其实指的就是方法区。由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出。最典型的场景就是, 在 jsp 页面比较多的情况,容易出现永久代内存溢出。
JDK1. 8+
现在我们在 JDK 8下重新运行一下案例代码,不过这次不再指定 PermSize 和 MaxPermSize。而是指定 MetaSpaceSize 和 MaxMetaSpaceSize的大小。输出结果如下:
[外链图片转存中…(img-5b43wUNV-1695191042191)]
从输出结果,我们可以看出,这次不再出现永久代溢出,而是出现了元空间的溢出。
4.2 字符串OOM异常
案例代码
以下这段程序以2的指数级不断的生成新的字符串,这样可以比较快速的消耗内存:
package com.dev.test.memory;
import java.util.ArrayList;
import java.util.List;
public class StringOomMock {
static String base = "string";
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
for (int i=0;i< Integer.MAX_VALUE;i++){
String str = base + base;
base = str;
list.add(str.intern());
}
}
}
JDK1. 6
JDK 1.6 的运行结果:
[外链图片转存中…(img-njUyem9j-1695191042192)]
JDK 1.6下,会出现永久代的内存溢出。
JDK1. 7
JDK 1.7的运行结果:
[外链图片转存中…(img-aKJdkay1-1695191042192)]
在 JDK 1.7中,会出现堆内存溢出。结论是:JDK 1.7 已经将字符串常量由永久代转移到堆中。
JDK1. 8+
JDK 1.8的运行结果:
[外链图片转存中…(img-B4h5XnoH-1695191042193)]
在JDK 1.8 中,也会出现堆内存溢出,并且显示 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以验证 JDK 1.8 中已经不存在永久代的结论。
5.运行时常量池和字符串常量池
在JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
在JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代
在JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
5.1 运行时常量池存储内容
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用, 这部分内容将在类加载后进入方法区的运行时常量池中存放。(class常量池)
1)字面量:
双引号引起来的字符串值,“dev”
定义为final类型的常量的值。
2)符号引用:
类或接口的全限定名(包括他的父类和所实现的接口)
变量或方法的名称
变量或方法的描述信息
方法的描述:参数个数、参数类型、方法返回类型等等
变量的描述信息:变量的返回值
this
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中, 这种特性被开发人员利用比较多的就是String类的intern()方法。
5.2 存储位置
在JDK1.6及以前,运行时常量池是方法区的一部分。
在JDK1.7及以后,运行时常量池在Java 堆(Heap)中。
运行时和class常量池一样,运行时常量池也是每个类都有一个。但是字符串常量池全只有一个
5.3 常量池区别
class常量池(静态常量池)、运行时常量池、字符串常量池区别:
- class常量池中存储的是符号引用,而运行时常量池存储的是被解析之后的直接引用。
- class常量池存在于class文件中,运行时常量池和字符串常量池是存在于JVM内存中。
- 运行时常量池具有动态性,java运行期间也可能将新的常量放入池中(String#intern()),
- 字符串常量池逻辑上属于运行时常量池的一部分,但是它和运行时常量池的区别在于,字符串常量池是全局唯一的,而运行时常量池是每个类一个。
为什么字符串常量池全局唯一?
String类型是引用类型,需要产生对象。字符串太常用了,类中创建的字符串由很多是相同的,如果每一个相同的字符串都在堆中产生对象,会耗费大量内存,为了节约内存,将字符串常量池单独拎出来,相同的字符串只有一个,节约内存
如何在全局唯一的字符串常量池中去判断是否存在相同的字符串?
5.4 字符串常量池如何存储数据
实际上,为了提高匹配速度,即更快的查找某个字符串是否存在于常量池,Java在设计字符串常量池的时候,还搞了一张stringtable,stringtable 有点类似于我们的hashtable,里面保存了字符串的引用。
在jdk6中StringTable的长度是固定的,就是1009,因此如果放入String Pool中的String非常多, 就会造成hash冲突,导致链表过长。此时当调用 String.intern()时会需要到链表上一个一个找,从 而导致性能大幅度下降;
在jdk7+,StringTable的长度可以通过一个参数指定:
-XX:StringTableSize=99991
stringtable是类似于hashtable的数据结构,hashtable数据结构如下:
字符串常量池查找字符串的方式:
1)根据字符串的hashcode 找到对应entry。如果没冲突,它可能只是一个entry,如果有冲突,它 可能是一个entry链表,然后Java再遍历entry链表,匹配引用对应的字符串。
2)如果找得到字符串,返回引用。如果找不到字符串,会把字符串放到常量池,并把引用保存到 stringtable里。
5.5 字符串常量池介绍
上面我们已经稍微了解过字符串常量池了,它是java为了节省空间而设计的一个内存区域,java中所有的类共享一个字符串常量池。
比如A类中需要一个“hello”的字符串常量,B类也需要同样的字符串常量,他们都是从字符串常量池中获取的字符串,并且获得得到的字符串常量的地址是一样的。
5.6 字符串常量池案例分析
1、单独使用””引号创建的字符串都是常量,编译期就已经确定存储到String Pool中。
2、使用new String(“”)创建的对象会存储到heap(堆)中,是运行期新创建的。
3、使用只包含常量的字符串连接符如”aa”+”bb”创建的也是常量,编译期就能确定已经存储到StringPool中。
4、使用包含变量的字符串连接如”aa”+s创建的对象是运行期才创建的,存储到heap(堆)中。
5、运行期调用String的intern()方法可以向String Pool中动态添加对象。
public class Test {
public void test() {
String str1 = "abc";
String str2 = new String("abc");
System.out.println(str1 == str2); //false,一个在字符串常量池,一个是在堆中创建一个字符串对象;一个值,一个是内存地址,false
String str3 = new String("abc");
System.out.println(str3 == str2); //false, 两个对象;内存地址不一样
String str4 = "a" + "b";
System.out.println(str4 == "ab"); //true, 两个都是在编译期就放在字符串常量池中,相同的常量只存储一个
final String s = "a"; //final修饰的值视为常量
String str5 = s + "b";
System.out.println(str5 == "ab"); //true, 因为a是在编译器就在字符串常量池,s+"b"= "a"+b,都是存储在字符串常量池中
String s1 = "a"; //没有final修饰,在编译器是不可知的,只有在运行期确定值
String s2 = "b";
String str6 = s1 + s2; //new String("ab")
System.out.println(str6 == "ab"); //false ,一个在编译器存放在字符串常量池,一个在运行期创建在堆中
String str7 = "abc".substring(0, 2); //new String;调用方法都是new String
System.out.println(str7 == "ab"); //false
String str8 = "abc".toUpperCase(); //new String
System.out.println(str8 == "ABC"); //false
String s3 = "ab";
String s4 = "ab" + getString(); //getString()是变量,整体是堆中创建
System.out.println(s3 == s4); //false
String s5 = "a";
String s6 = "abc";
String s7 = s5 + "bc"; //运行期new String("abc")
System.out.println(s6 == s7.intern()); //true //intern()判断值不是在常量池中,会将值存到常量池
}
private String getString(){
return "c";
}
}
分析1
String str1 = "abc";
System.out.println(str1 == "abc");
步骤:
1) 栈中开辟一块空间存放引用str1,
2) String池中开辟一块空间,存放String常量"abc", 3) 引用str1指向池中String常量"abc",
4) str1所指代的地址即常量"abc"所在地址,输出为true
分析2
String str2 = new String("abc");
System.out.println(str2 == "abc");
步骤:
1) 栈中开辟一块空间存放引用str2,
2) 堆中开辟一块空间存放一个新建的String对象"abc",
3) 引用str2指向堆中的新建的String对象"abc",
4) str2所指代的对象地址为堆中地址,而常量"abc"地址在池中,输出为false
分析3
String str2 = new String("abc");
String str3 = new String("abc");
System.out.println(str3 == str2);
步骤:
1) 栈中开辟一块空间存放引用str3,
2) 堆中开辟一块新空间存放另外一个(不同于str2所指)新建的String对象, 3) 引用str3指向另外新建的那个String对象
4) str3和str2指向堆中不同的String对象,地址也不相同,输出为false
分析4
String str4 = "a" + "b";
System.out.println(str4 == "ab");
步骤:
1) 栈中开辟一块空间存放引用str4,
2) 根据编译器合并已知量的优化功能,池中开辟一块空间,存放合并后的String常量"ab", 3) 引用str4指向池中常量"ab",
4) str4所指即池中常量"ab",输出为true
分析5
final String s = "a";
String str5 = s + "b";
System.out.println(str5 == "ab");
步骤:
同4
分析6
String s1 = "a"; String s2 = "b";
String str6 = s1 + s2;
System.out.println(str6 == "ab");
步骤:
1) 栈中开辟一块中间存放引用s1,s1指向池中String常量"a",
2) 栈中开辟一块中间存放引用s2,s2指向池中String常量"b",
3) 栈中开辟一块中间存放引用str6,
4) s1 + s2通过StringBuilder的最后一步toString()方法还原一个新的String对象"ab",因此 堆中开辟一块空间存放此对象,
5) 引用str6指向堆中(s1 + s2)所还原的新String对象,
6) str6指向的对象在堆中,而常量"ab"在池中,输出为false
分析7
String str7 = "abc".substring(0, 2);
System.out.println(str7 == "ab");
步骤:
1) 栈中开辟一块空间存放引用str7,
2) substring()方法还原一个新的String对象"ab"(不同于str6所指),堆中开辟一块空间存放此 对象,
3) 引用str7指向堆中的新String对象,
分析8
String str8 = "abc".toUpperCase();
System.out.println(str8 == "ABC");
步骤:
1) 栈中开辟一块空间存放引用str8,
2) toUpperCase()方法还原一个新的String对象"ABC",池中并未开辟新的空间存放String常 量"ABC",
3) 引用str8指向堆中的新String对象
5.7 String的Intern方法详解
先让大家做个面试题:
String a = "hello";
String b = new String("hello");
System.out.println(a == b); //false
String c = "world";
System.out.println(c.intern() == c); //true world在常量池中存在
String d = new String("mike");
System.out.println(d.intern() == d); //false //new String存在堆中,d.intern是在字符串常量池中
String e = new String("jo") + new String("hn"); //e是new String()
System.out.println(e.intern() == e); //true // john在常量池中没有 所以e.intern对象的引用在常量池中没有,当e.intern将值存到常量池后,返回的是堆中类的对象引用
String f = new String("ja") + new String("va");
System.out.println(f.intern() == f); //false //"java"是在常量池中内嵌的字符串 所以所以e.intern对象的引用在常量池中存在,返回的是常量池中字符串的引用
如果大家能一题不差的全做对,接下来的内容应该不用看了。如果不能并且有兴趣的话,可以稍微了解 一下以下内容。
public class TestIntern {
public static void main(String[] args) {
// String f = new String("abs") + new String("tract"); //t
// String f = new String("br") + new String("eak"); //t
// String f = new String("cat") + new String("ch"); //t
// String f = new String("cla") + new String("ss"); //t
// String f = new String("con") + new String("tinue"); //t
// String f = new String("d") + new String("o"); //t
// String f = new String("el") + new String("se"); //t
// String f = new String("ex") + new String("tends"); //t
// String f = new String("fin") + new String("al"); //t
// String f = new String("fin") + new String("ally"); //t
// String f = new String("f") + new String("or"); //t
// String f = new String("i") + new String("f"); //t
// String f = new String("imp") + new String("lements"); //t
// String f = new String("im") + new String("port"); //t
// String f = new String("instance") + new String("of"); //t
// String f = new String("inter") + new String("face"); //t
// String f = new String("na") + new String("tive"); //t
// String f = new String("n") + new String("ew"); //t
// String f = new String("pack") + new String("age"); //t
// String f = new String("pri") + new String("vate"); //t
// String f = new String("protect") + new String("ed"); //t
// String f = new String("pub") + new String("lic"); //t
// String f = new String("sta") + new String("tic"); //t
// String f = new String("su") + new String("per"); //t
// String f = new String("sw") + new String("itch"); //t
// String f = new String("synchronize") + new String("d"); //t
// String f = new String("th") + new String("is"); //t
// String f = new String("th") + new String("row"); //t
// String f = new String("th") + new String("rows"); //t
// String f = new String("trans") + new String("ient"); //t
// String f = new String("tr") + new String("y"); //t
// String f = new String("vola") + new String("tile"); //t
// String f = new String("whi") + new String("le"); //t
// --------分割线-------
// String f = new String("boo") + new String("lean"); //f
// String f = new String("by") + new String("te"); //f
// String f = new String("ch") + new String("ar"); //f
// String f = new String("de") + new String("fault"); //f
// String f = new String("dou") + new String("ble"); //f
// String f = new String("fal") + new String("se"); //f
// String f = new String("flo") + new String("at"); //f
// String f = new String("in") + new String("t"); //f
// String f = new String("l") + new String("ong"); //f
// String f = new String("nu") + new String("ll"); //f
// String f = new String("sh") + new String("ort"); //f
// String f = new String("tr") + new String("ue"); //f
// String f = new String("vo") + new String("id"); //f
String f = new String("ja") + new String("va"); //f
System.out.println(f.intern() == f);
}
}
intern的作用
intern的作用是把new出来的字符串的引用添加到stringtable中,java会先计算string的hashcode,查找stringtable中是否已经有string对应的引用了,如果有返回引用(地址),然后没有把字符串的地址放到stringtable中,并返回字符串的引用(地址)。
我们继续看例子:
String a = new String("haha");
System.out.println(a.intern() == a);//false
因为有双引号括起来的字符串,所以会把ldc命令,即"haha"会被我们添加到字符串常量池,它的引用是string的char数组的地址,会被我们添加到stringtable中。所以a.intern的时候,返回的其实是string中的char数组的地址,和a的string实例化地址肯定是不一样的。
String e = new String("jo") + new String("hn");
System.out.println(e.intern() == e);//true
new String(“jo”) + new String(“hn”)实际上会转为stringbuffer的append 然后tosring()出来,实际上是new 一个新的string出来。在这个过程中,并没有双引号括起john,也就是说并不会执行ldc然后把john的引用添加到stringtable中,所以intern的时候实际就是把新的string地址(即e的地址)添加到stringtable中并且返回回来。
String f = new String("ja") + new String("va");
System.out.println(f.intern() == f);//false
或许很多朋友感觉很奇怪,这跟上面的例子2基本一模一样,但是却是false呢?这是因为java在启动的时候,会把一部分的字符串添加到字符串常量池中,而这个“java”就是其中之一。所以intern回来的引用是早就添加到字符串常量池中的”java“的引用,所以肯定跟f的原地址不一样。
JDK6中的理解
Jdk6中字符串常量池位于PermGen( 永久代) 中, PermGen是一块主要用于存放已加载的类信息和字符串池的大小固定的区域。
执行intern()方法时,若常量池中不存在等值的字符串,JVM就会在常量池中创建一个等值的字符串,然后返回该字符串的引用。除此以外,JVM 会自动在常量池中保存一份之前已使用过的字符串集合。
Jdk6中使用intern()方法的主要问题就在于字符串常量池被保存在PermGen中:
首先, PermGen是一块大小固定的区域, 一般不同的平台PermGen的默认大小也不相同,大致在32M到96M之间。 所以不能对不受控制的运行时字符串(如用户输入信息等)使用intern()方法,否则很有可能会引发PermGen内存溢出;
其次String对象保存在Java堆区, Java堆区与PermGen是物理隔离的, 因此如果对多个不等值的字符串对象执行intern操作, 则会导致内存中存在许多重复的字符串, 会造成性能损失。
JDK7+的理解
Jdk7将常量池从PermGen区移到了Java堆区。 堆区的大小一般不受限,所以将常量池从PremGen区移到堆区使得常量池的使用不再受限于固定大小。可以使用 -XX: StringTableSize 虚拟机参数设置字符串池的map大小。
字符串池内部实现为一个HashMap,所以当能够确定程序中需要intern的字符串数目时,可以将该map的size设置为所需数目*2(减少hash冲突),这样就可以使得String.intern()每次都只需要常量时间和相当小的内存就能够将一个String存入字符串池中。
执行intern操作时,如果常量池已经存在该字符串,则直接返回字符串引用,否则复制该字符串对象的引用到常量池中并返回。
除此之外,位于堆区的常量池中的对象可以被垃圾回收。当常量池中的字符串不再存在指向它的引用时,JVM就会回收该字符串。
intern案例分析
public static void main(String[] args) {
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);
String s3 = new String("1") + new String("1");
s3.intern(); // //jdk1.6 Perm区和正常的 JAVA Heap 区域是完全分开的;
//1.7 .intern()如果发现常量池不存在整个字符串的的话,将字符串存引用放到常量池中,并返回字符串对象的引用
String s4 = "11";
System.out.println(s3 == s4);
}
打印结果是
jdk6 下 false false
jdk7 下 false true
jdk1.6堆与永久代(方法区),不在一起,所以在 s.intern(),是将"11"复制到永久代(方法区)中
jdk1.7永久代(方法区)是在堆中,所以在 s.intern(),只是将常量池中的值是Java堆中的地址引用
具体为什么稍后再解释,然后将s3.intern(); 语句下调一行,放到String s4 = “11”; 后面。将s.intern(); 放到String s2 = “1”; 后面。是什么结果呢?
public static void main(String[] args) {
String s = new String("1");
String s2 = "1";
s.intern();
System.out.println(s == s2);
String s3 = new String("1") + new String("1");
String s4 = "11"; jdk1.6 Perm区和正常的 JAVA Heap 区域是完全分开的;1.7 .intern()如果发现常量池存在整个字符串的的话,直接将字符串引用返回回来
s3.intern();
System.out.println(s3 == s4);
}
打印结果为:
jdk6 下 false false
jdk7 下 false false
先将常量池中存放了"11",然后再s3.intern();,返回的是常量池中"11"的引用,并不是堆对象中的引用了
jdk6中的解释
如上图所示。在 jdk6中上述的所有打印都是 false 的,因为 jdk6中的常量池是放在 Perm 区中的,Perm区和正常的 JAVA Heap 区域是完全分开的。上面说过如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而 new 出来的 String 对象是放在 JAVA Heap 区域。所以拿一个 JAVAHeap 区域的对象地址和字符串常量池的对象地址进行比较肯定是不相同的,即使调用 String.intern方法也是没有任何关系的。
jdk7中的解释
在 Jdk6 以及以前的版本中,字符串的常量池是放在堆的Perm区的,Perm区是一个类静态的区域,主要存储一些加载类的信息,常量池,方法片段等内容,默认大小只有4m,一旦常量池中大量使用intern 是会直接产生java.lang.OutOfMemoryError:PermGen space错误的。
在 jdk7 的版本中,字符串常量池已经从Perm区移到正常的Java Heap区域了。为什么要移动,Perm区域太小是一个主要原因,而且jdk8已经直接取消了Perm区域,而新建立了一个元区域。应该是jdk开发者认为Perm区域已经不适合现在 JAVA 的发展了。正式因为字符串常量池移动到JAVA Heap区域后,再来解释为什么会有上述的打印结果。
在第一段代码中,先看 s3和s4字符串。 String s3 = new String(“1”) + new String(“1”); ,这句代码中现在生成了2最终个对象,是字符串常量池中的“1” 和 JAVA Heap中的 s3引用指向的对象。中间还有2个匿名的 new String(“1”) 我们不去讨论它们。此时s3引用对象内容是”11″,但此时常量池中是没有 “11”对象的。
接下来s3.intern(); 这一句代码,是将 s3中的"11"字符串放入String 常量池中,因为此时常量池中不存在"11"字符串,因此常规做法是跟 jdk6 图中表示的那样,在常量池中生成一个"11"的对象,关键点是 jdk7 中常量池不在Perm区域了,这块做了调整。常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用指向s3引用的对象。 也就是说引用地址是相同的。
最后String s4 = “11”; 这句代码中”11″是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向s3引用对象的一个引用。所以s4引用就指向和s3一样了。因此最后的比较 s3 == s4 是 true。
再看s和 s2 对象。 String s = new String(“1”); 第一句代码,生成了2个对象。常量池中的“1” 和 JAVA Heap 中的字符串对象。 s.intern(); 这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。
接下来String s2 = “1”; 这句代码是生成一个 s2的引用指向常量池中的“1”对象。 结果就是 s 和 s2 的引用地址明显不同。图中画的很清晰。
来看第二段代码,从上边第二幅图中观察。第一段代码和第二段代码的改变就是 s3.intern();的顺序是放在String s4 = “11”; 后了。这样,首先执行String s4 = “11”; 声明 s4 的时候常量池中是不存在“11”对象的,执行完毕后,“11“对象是 s4 声明产生的新对象。然后再执行s3.intern(); 时,常量池中“11”对象已经存在了,因此 s3 和 s4 的引用是不同的。
第二段代码中的 s 和 s2 代码中,s.intern(); ,这一句往后放也不会有什么影响了,因为对象池中在执行第一句代码String s = new String(“1”); 的时候已经生成“1”对象了。下边的s2声明都是直接从常量池中取地址引用的。 s 和 s2 的引用地址是不会相等的。
小结
从上述的例子代码可以看出 jdk7 版本对 intern 操作和常量池都做了一定的修改。主要包括2点:
1)将String常量池从Perm区移动到了Java Heap区
2)String#intern 方法时,如果存在堆中的对象,会直接保存对象的引用,而不会重新创建对象。
intern方法的好处
如果在字符串拼接中,有一个参数是非字面量,而是一个变量的话,整个拼接操作会被编译成StringBuilder.append,这种情况编译器是无法知道其确定值的。只有在运行期才能确定。
String s3 = new String("1") + new String("1");
那么,有了这个特性了,intern就有用武之地了。那就是很多时候,我们在程序中得到的字符串是只有在运行期才能确定的,在编译期是无法确定的,那么也就没办法在编译期被加入到常量池中。
这时候,对于那种可能经常使用的字符串,使用intern进行定义,每次JVM运行到这段代码的时候,就会直接把常量池中该字面值的引用返回,这样就可以减少大量字符串对象的创建了。
static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];
public static void main(String[] args) throws Exception {
Integer[] DB_DATA = new Integer[10];
Random random = new Random(10 * 10000);
for (int i = 0; i < DB_DATA.length; i++) {
DB_DATA[i] = random.nextint();
}
long t = System.currentTimeMillis();
for (int i = 0; i < MAX; i++) {
arr[i] = new String(String.valueOf(DB_DATA[i %DB_DATA.length])).intern();
}
System.out.println((System.currentTimeMillis() - t) + "ms");
System.gc();
}
以上程序会有很多重复的相同的字符串产生,但是这些字符串的值都是只有在运行期才能确定的。所以,只能我们通过intern显示的将其加入常量池,这样可以减少很多字符串的重复创建。
Jdk6 中常量池位于PremGen区,大小受限,不建议使用String.intern()方法,不过Jdk7 将常量池移到了Java堆区,大小可控,可以重新考虑使用String.intern()方法,但是由对比测试可知,使用该方法的耗时不容忽视,所以需要慎重考虑该方法的使用;
String. intern() 方法主要适用于程序中需要保存有限个会被反复使用的值的场景,这样可以减少内存消耗,同时在进行比较操作时减少时耗,提高程序性能。
6. Java堆
6.1 概念
Java堆被所有线程共享,在Java虚拟机启动时创建。是虚拟机管理最大的一块内存。
Java堆是垃圾回收的主要区域,而且主要采用分代回收算法。堆进一步划分主要是为了更好的回收内存或更快的分配内存。
6.2 存储内容
Java虚拟机规范的描是:所有的对象实例以及数组都要在堆上分配。
不过随着JIT编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、 标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对” 了。
6.3 存储方式
堆内存空间在物理上可以不连续,逻辑上连续即可。
6.4 堆内存划分
<1.8
新生代
Eden空间[伊甸园]
From Survivor空间
To Survivor空间
老年代
永久代
=1.8
新生代
Eden空间[伊甸园]
From Survivor空间
To Survivor空间
老年代
Meta Space(元空间,独立与JVM内存的一块内存空间)
堆大小 = 新生代 + 老年代。
在Java1.8中移除整个永久代,取而代之的是一个叫元空间的区域(永久代使用的是jvm内存区域,元空间使用的是物理内存,直接受到本机的物理内存限制)
堆的大小可通过参数 –Xms(堆的初始容量) 、 -Xmx(堆的最大容量) 来指定。
- 其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。默认的,Edem : from : to = 8 : 1 : 1 。(可以通过参数 –XX:SurvivorRatio 来设定 。即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。
- JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。
- 新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
6.5 对象创建
Student stu = new Student();
6.6 内存的分配原则
序号 | 介绍 |
---|---|
1 | 优先在 Eden 分配,如果 Eden 空间不足虚拟机则会进行一次 MinorGC |
2 | 大对象直接接入老年代,大对象一般指的是很长的字符串或数组 |
3 | 长期存活的对象进入老年代,每个对象都有一个age,当age到达设定的年龄的时候就会进入老年代,默认是15岁。 |
6.7 内存分配方式
内存分配的方法有两种:指针碰撞(Bump the Pointer)和空闲列表(Free List)
分配方法 | 说明 | 收集器 |
---|---|---|
指针碰撞 | 内存地址是连续的 | Serial 和ParNew 收集器 |
空闲列表 | 内存地址不连续 | CMS 收集器和Mark-Sweep 收集器 |
6.8 内存分配安全问题
在分配内存的同时,存在线程安全的问题,即虚拟机给A线程分配内存过程中,指针未修改,B线程可能同时使用了同样一块内存。
在JVM中有两种解决办法:
-
CAS,比较和交换(Compare And Swap): CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
-
TLAB,本地线程分配缓冲(Thread Local Allocation Buffer即TLAB): 为每一个线程预先分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。
6.9 对象的内存布局
对象在内存中存储的布局可以分为三块区域:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。
1) 对象头
对象头包括两部分信息:
一部分是用于存储对象自身的运行数据,如哈希码(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。
另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪一个类的实例。当对象是一个java数组的时候,那么对象头还必须有一块用于记录数组长度的数据,因此虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中无法确定数组的大小。
2) 实例数据
存储的是对象真正有效的信息。
3) 对齐填充
这部分并不是必须要存在的,没有特别的含义,在jvm中对象的大小必须是8字节的整数倍,而对象头也是8字节的倍数,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
6.10 对象访问方式
方式 | 优点 |
---|---|
句柄 | 稳定,对象被移动只要修改句柄中的地址 |
直接指针(主要) | 访问速度快,节省了一次指针定位的开销 |
6.11 数组的内存分析
一维数组
int[] arr1 = new int[3];
先把 arr1 压进栈,然后在堆空间中开辟一个空间,并把值初始化为0(arr1为引用变量,但是内部数据是int类型,默认值为 0),最后把 开辟的堆空间地址 赋值给arr1
int[] arr2 = arr1;
把 arr1 中的 地址 赋值给 arr2,此时 arr2 和 arr1 指向同一块空间。
arr2[0] = 20;
此时,arr1[0] 值为 20。
二维数组
int[][] array = new int[3][];
这条语句会先把 array 压栈,然后在堆中开辟一个空间,初始值为 null(array为引用变量,第一维同样是引用类型),最后把开辟的堆空间地址赋值给 array。
array[0][] = new int[1]
这条语句会在堆空间中开辟一个 只有一个 int 类型大小的空间,并初始化为 0 ,然后把自己的地址赋值给array[0][]。
array[1][] = new int[2];
array[2][] = new int[3];
这两条语句和上一条意义一样,就不再做解释
7. 程序计数器
7.1 作用
程序计数器(Program Counter Register),也叫PC寄存器,是一块较小的内存空间,它可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程回复等都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(针对多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
7.2 存储的数据
如果一个线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个Native方法,这个计数器的值则为空。
7.3 异常
此内存区域是唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError异常情况的区域。
8. Java虚拟机栈
虚拟机栈也是线程私有,而且生命周期与线程相同,每个Java方法在执行的时候都会创建一个栈帧(Stack Frame)。
栈内存为线程私有的空间,每个线程都会创建私有的栈内存。栈空间内存设置过大,创建线程数量较多时会出现栈内存溢出StackOverflowError。同时,栈内存也决定方法调用的深度,栈内存过小则会导致方法调用的深度较小,如递归调用的次数较少。
8.1 栈帧
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
一个线程中方法的调用链可能会很长,很多方法都同时处于执行状态。对于JVM执行引擎来说,在在活动线程中,只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法, 定义这个方法的类叫做当前类。
执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。
调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧。
关于「栈帧」,我们在看看《Java虚拟机规范》中的描述:
栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态连接、方法返回值和异常分派。
栈帧随着方法调用而创建,随着方法结束而销毁——无论方法正常完成还是异常完成都算作方法结束。
栈帧的存储空间由创建它的线程分配在Java虚拟机栈之中,每一个栈帧都有自己的本地变量表(局部变量表)、操作数栈和指向当前方法所属的类的运行时常量池的引用。
接下来,详细讲解一下栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的数据结构和作用。
局部变量表
存储内容
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。
一个局部变量可以保存一个类型为 boolean、 byte、 char、 short、 int、 float、 reference和returnAddress类型 的数据。reference类型表示对一个对象实例的引用。returnAddress类型是为jsr、jsr_w和ret指令服务的,目前已经很少使用了。(保存基本类型与引用类型数据)
存储容量
局部变量表的容量以变量槽(Variable Slot)为最小单位,Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位(4个字节)以内的数据类型。
在Java程序编译为Class文件时,就在方法的Code属性中的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。(最大Slot数量)
其他
虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从 0~局部变量表最大容量 。如果Slot是32位的,则遇到一个64位数据类型的变量(如long或double型)时,会连续使用两个连续的Slot来存储。
操作数栈
作用
操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出栈(LIFO) 。当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。
存储内容
操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量。
存储容量
同局部变量表一样,操作数栈的最大深度也在编译的时候写入到方法的Code属性的 max_stacks 数据项中。且在方法执行的任意时刻,操作数栈的深度都不会超过 max_stacks 中设置的最大值。
动态连接
在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。
这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。
方法返回
当一个方法开始执行时, 可能有两种方式退出该方法:
- 正常完成出口
- 异常完成出口
正常完成出口是指方法正常完成并退出,没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常)。如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定。
异常完成出口是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。
无论是Java虚拟机抛出的异常还是代码中使用athrow指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。
无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。
方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者的操作数栈中,调整PC计数器的值以指向方法调用指令后的下一条指令。
一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧之中,例如和调试相关的信息,这部分信息完全取决于不同的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其他附加信息一起归为一类,称为栈帧信息。
8.2 栈异常
Java虚拟机规范中,对该区域规定了这两种异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出 StackOverfl owError 异常;
- 虚拟机栈可以动态拓展,当扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
package com.dev.test.memory;
public class StackErrorMock {
private static int index = 1;
public void call(){
index++;
call();
}
public static void main(String[] args) {
StackErrorMock mock = new StackErrorMock();
try {
mock.call();
}
catch (Throwable e){
System.out.println("Stack deep : "+index);
e.printStackTrace();
}
}
}
9. 本地方法栈
本地方法栈和虚拟机栈相似,区别就是虚拟机栈为虚拟机执行Java服务( 字节码服务) ,而本地方法栈为虚拟机使用到的Native方法( 比如C++方法) 服务。
9.1 本地方法介绍
什么是本地方法
简单地讲,一个Native Method就是一个java调用非java代码的接口。
"A native method is a Java method whose implementation is provided by non-java code."
一个Native Method是这样一个java的方法: 该方法的实现由非java语言实现, 比如C。
在定义一个native method时,并不提供实现体(有些像定义一个java interface),因为其实现体是由非java语言在外面实现的。下面给了一个示例:
public class IHaveNatives
{
native public void Native1( int x ) ;
native static public long Native2() ;
native synchronized private float Native3( Object o ) ;
native void Native4( int[] ary ) throws Exception ;
}
这些方法的声明描述了一些非java代码在这些java代码里看起来像什么样子。
标识符native可以与所有其它的java标识符连用, 但是abstract除外。 这是合理的,因为native暗示这些方法是有实现体的,只不过这些实现体是非java的,但是abstract却显然的指明这些方法无实现体。
native与其它java标识符连用时, 其意义同非Native Method并无差别,比如native static表明这个方法可以在不产生类的实例时直接调用,这非常方便,比如当你想用一个native method去调用一个C的类库时。上面的第三个方法用到了native synchronized,JVM在进入这个方法的实现体之前会执行同步锁机制(就像java的多线程。)
一个native method方法可以返回任何java类型, 包括非基本类型, 而且同样可以进行异常控制。这些方法的实现体可以制一个异常并且将其抛出,这一点与java的方法非常相似。
当一个native method接收到一些非基本类型时如Object或一个整型数组时, 这个方法可以访问这些非基本型的内部, 但是这将使这个native方法依赖于你所访问的java类的实现。 有一点要牢牢记住:我们可以在一个native method的本地实现中访问所有的java特性,但是这要依赖于你所访问的java特性的实现,而且这样做远远不如在java语言中使用那些特性方便和容易。
native method的存在并不会对其他类调用这些本地方法产生任何影响, 实际上调用这些方法的其他类甚至不知道它所调用的是一个本地方法。 JVM将控制调用本地方法的所有细节。 需要注意当我们将一个本地方法声明为final的情况。用java实现的方法体在被编译时可能会因为内联而产生效率上的提升。但是一个native final方法是否也能获得这样的好处却是值得怀疑的,但是这只是一个代码优化方面的问题,对功能实现没有影响。
如果一个含有本地方法的类被继承, 子类会继承这个本地方法并且可以用java语言重写这个方法( 这个似乎看起来有些奇怪) , 同样的如果一个本地方法被fianl标识, 它被继承后不能被重写。
本地方法非常有用, 因为它有效地扩充了jvm。 事实上,我们所写的java代码已经用到了本地方法,在sun的java的并发(多线程)的机制实现中,许多与操作系统的接触点都用到了本地方法,这使得java程序能够超越java运行时的界限。有了本地方法,java程序可以做任何应用层次的任务。
为什么要使用本地方法
java使用起来非常方便,然而有些层次的任务用java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
有时java应用需要与java外面的环境交互。 这是本地方法存在的主要原因, 你可以想想java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。 本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐的细节。
JVM支持着java语言本身和运行时库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎 样,它毕竟不是一个完整的系统,它经常依赖于一些底层(underneath在下面的)系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的,还有,如果我们要使用一些java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
JVM怎样使本地方法跑起来
我们知道,当一个类第一次被使用到时,这个类的字节码会被加载到内存,并且只会回载一次。在这个被加载的字节码的入口维持着一个该类所有方法描述符的list,这些方法描述符包含这样一些信息:方法代码存于何处,它有哪些参数,方法的描述符(public之类)等等。
如果一个方法描述符内有native,这个描述符块将有一个指向该方法的实现的指针。这些实现在一些DLL文件内,但是它们会被操作系统加载到java程序的地址空间。当一个带有本地方法的类被加载时,其相关的DLL并未被加载,因此指向方法实现的指针并不会被设置。当本地方法被调用之前,这些DLL才会被加载,这是通过调用java.system.loadLibrary()实现的。
最后需要提示的是,使用本地方法是有开销的,它丧失了java的很多好处。如果别无选择,我们可以选择使用本地方法。
9.2 本地方法栈的使用流程
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,但不止如此,它还可以做任何它想做的事情。
本地方法本质上时依赖于实现的,虚拟机实现的设计者们可以自由地决定使用怎样的机制来让Java程序调用本地方法。
任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。
如果某个虚拟机实现的本地方法接口是使用C连接模型的话, 那么它的本地方法栈就是C栈。当C程序调用一个C函数时,其栈操作都是确定的。传递给该函数的参数以某个确定的顺序压入栈,它的返回值也以确定的方式传回调用者。同样,这就是虚拟机实现中本地方法栈的行为。
很可能本地方法接口需要回调Java虚拟机中的Java方法,在这种情况下,该线程会保存本地方法栈的状态并进入到另一个Java栈。
下图描绘了这样一个情景, 就是当一个线程调用一个本地方法时, 本地方法又回调虚拟机中的另一个Java方法。
这幅图展示了JAVA虚拟机内部线程运行的全景图。一个线程可能在整个生命周期中都执行Java方法,操作它的Java栈;或者它可能毫无障碍地在Java栈和本地方法栈之间跳转。
该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了 一个本地方法栈。假设这是一个C语言栈,其间有两个C函数,第一个C函数被第二个Java方法当做本地 方法调用,而这个C函数又调用了第二个C函数。之后第二个C函数又通过本地方法接口回调了一个Java 方法(第三个Java方法),最终这个Java方法又调用了一个Java方法(它成为图中的当前方法)。