JVM虚拟机概述
什么是JVM?
平时我们所说的JVM广义上指的是一种规范。狭义上的是JDK中的JVM虚拟机。JVM的实现是由各个厂商
来做的。比如现在流传最广泛的是hotspot。其他实现:BEA公司 JRocket、IBM j9、zing 号称世界最快
JVM、taobao.vm。从广义上讲Java,Kotlin、Clojure、JRuby、Groovy等运行于Java虚拟机上的编程语
言及其相关的程序都属于Java技术体系中的一员。
Java技术体系主要包括如下四个方面。
- Java程序设计语言
- Java类库API
- 来自商业机构和开源社区的第三方Java类库
- Apache
- 等等
- Java虚拟机:各种硬件平台上的Java虚拟机实现
可以简单类比一下:Java虚拟机是宿主,Java代码开发的程序则寄生在宿主上!
JVM架构图
Java和JVM的关系:
类加载子系统
类加载的时机
类加载主要有四个时机:
遇到 new 、 getstatic 、 putstatic 和 invokestatic 这四条指令时,如果对应的类没有初始化,则要对对应的类先进行初始化。
public class Student{ private static int age ; public static void method(){ } } //Student.age //Student.method(); //new Student();
使用 java.lang.reflect 包方法时,对类进行反射调用的时候。
Class c = Class.forname("com.hero.Student");
初始化一个类的时候发现其父类还没初始化,要先初始化其父类
当虚拟机开始启动时,用户需要指定一个主类(main),虚拟机会先执行这个主类的初始化。
类加载的过程
类加载主要做三件事:
全限定名称 ==> 二进制字节流加载class文件
字节流的静态数据结构 ==> 方法区的运行时数据结构
创建字节码Class对象
一个类的一生:
可以从哪些途径加载字节码?
类加载器
JVM的类加载是通过ClassLoader及其子类来完成的。
- 检查顺序是自底向上:加载过程中会先检查类是否被已加载,从Custom ClassLoader到BootStrap。ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有ClassLoader加载一次。
- 加载的顺序是自顶向下:也就是由上层来逐层尝试加载此类。
- 启动类加载器(Bootstrap ClassLoader):
- 负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类。由C++实现,不是ClassLoader的子类
- 扩展类加载器(Extension ClassLoader):
- 负责加载 JAVA_HOME\lib\ext 目录中的,或通过java.ext.dirs系统变量指定路径中的类库。
- 应用程序类加载器(Application ClassLoader):
- 负责加载用户路径classpath上的类库
- 自定义类加载器(User ClassLoader):
- 作用:JVM自带的三个加载器只能加载指定路径下的类字节码,如果某些情况下,我们需要加载应用程序之外的类文件呢?就需要用到自定义类加载器,就像是在汽车行驶的时候,为汽车更换轮子。
- 比如本地D盘下的,或者去加载网络上的某个类文件,这种情况就可以使用自定义加载器了。
- 举个栗子:JRebel
自定义类加载器案例
目标:自定义类加载器,加载指定路径在D盘下的lib文件夹下的类。
步骤:
新建一个需要被加载的类Test.jave
编译Test.jave到指定lib目录3. 自定义类加载器HeroClassLoader继承ClassLoader:
重写findClass()方法
调用defineClass()方法
测试自定义类加载器
实现:
(1)新建一个 Test.java 类,代码如下:
package com.hero.jvm.classloader;
public class Test {
public void say(){
System.out.println("Hello HeroClassLoader");
}
}
(2)使用 javac Test.java 命令,将生成的 Test.class 文件放到D:/lib/com/hero/jvm/classloader 文件夹下。
(3)自定义类加载器,代码如下:
package com.hero.jvm.classloader;
import java.io.;
public class HeroClassLoader extends ClassLoader {
private String classpath;
public HeroClassLoader(String classpath) {
this.classpath = classpath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException
{
try {
//输入流,通过类的全限定名称加载文件到字节数组
byte[] classDate = getData(name);
if (classDate != null) {
//defineClass方法将字节数组数据 转为 字节码对象
return defineClass(name, classDate, 0, classDate.length);
}
} catch (IOException e) {
e.printStackTrace();
}
return super.findClass(name);
}
//加载类的字节码数据
private byte[] getData(String className) throws IOException {
String path = classpath + File.separatorChar +
className.replace('.', File.separatorChar) + ".class";
try (InputStream in = new FileInputStream(path);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buffer = new byte[2048];
int len = 0;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
return out.toByteArray();
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return null;
}
}
(4)测试,代码如下:
package com.hero.jvm.classloader;
import java.lang.reflect.Method;
public class TestMyClassLoader {
public static void main(String []args) throws Exception{
//自定义类加载器的加载路径
HeroClassLoader hClassLoader=new HeroClassLoader("D:\\lib");
//包名+类名
Class c=hClassLoader.loadClass("com.hero.jvm.classloader.Test");
if(c!=null){
Object obj=c.newInstance();
Method method=c.getMethod("say", null);
method.invoke(obj, null);
System.out.println(c.getClassLoader().toString());
}
}
}
输出结果如下:
双亲委派模型与打破双亲委派
1)什么是双亲委派?
当一个类加载器收到类加载任务,会先交给其父类加载器去完成。因此,最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时,子类才会尝试执行加载任务。
Oracle 官网文档描述:
The Java Class Loading Mechanism
The Java platform uses a delegation model for loading classes. The basic idea is that every
class loader has a “parent” class loader. When loading a class, a class loader first “delegates”
the search for the class to its parent class loader before attempting to find the class itself.
—— Oracel Document
https://docs.oracle.com/javase/tutorial/ext/basics/load.html
看到这里,应该叫父亲委派对吧?那么为什么要叫双亲委派呢,因为最早的翻译者,导致双亲委派的概念流行起来了
2)为什么需要双亲委派呢?
考虑到安全因素,双亲委派可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。
比如:加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object对象。
3)双亲委派机制源码:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
//首先检查class是否被加载,如果没有加载则进行加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//如果父类加载不为空,则交给父类加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
//如果父类加载器没有加载到,则由子类进行加载
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
4)为什么还需要破坏双亲委派?
在实际应用中,双亲委派解决了Java 基础类统一加载的问题,但是却存在着缺陷。JDK中的基础类作为典型的api被用户调用,但是也存在api调用用户代码的情况,典型的如:SPI代码。这种情况就需要打破双亲委派模式。
举个栗子:数据库驱动DriverManager。以Driver接口为例,Driver接口定义在JDK中,其实现由各个数据库的服务商来提供,由系统类加载器加载。这个时候就需要启动类加载器来 委托 子类来加载Driver实现,这就破坏了双亲委派。类似情况还有很多
5)如何破坏双亲委派?
第一种方式
- 在 jdk 1.2 之前,那时候还没有双亲委派模型,不过已经有了 ClassLoader 这个抽象类,所以已经有人继承这个抽象类,重写 loadClass 方法来实现用户自定义类加载器。
- 而在 1.2 的时候要引入双亲委派模型,为了向前兼容, loadClass 这个方法还得保留着使之得以重写,新搞了个 findClass 方法让用户去重写,并呼吁大家不要重写 loadClass 只要重写 findClass。
- 这就是第一次对双亲委派模型的破坏,因为双亲委派的逻辑在 loadClass 上,但是又允许重写loadClass,重写了之后就可以破坏委派逻辑了。
第二种方式:
- 双亲委派机制是一种自上而下的加载需求,越往上类越基础。
- SPI代码打破了双亲委派
DriverManager源码
/
Load the initial JDBC drivers by checking the System property
jdbc.properties and then use the {@code ServiceLoader} mechanism
/
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
private static void loadInitialDrivers() {
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
// If the driver is packaged as a Service Provider, load it.
// Get all the drivers through the classloader
// exposed as a java.sql.Driver.class service.
// ServiceLoader.load() replaces the sun.misc.Providers()
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
/ Load these drivers, so that they can be instantiated.
It may be the case that the driver class may not be there
i.e. there may be a packaged driver with the service class
as implementation of java.sql.Driver but the actual class
may be missing. In that case a java.util.ServiceConfigurationError
will be thrown at runtime by the VM trying to locate
and load the service.
Adding a try catch block to catch those runtime errors
if driver not available in classpath but it's
packaged as service and that service is there in classpath.
/
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
println("DriverManager.initialize: jdbc.drivers = " + drivers);
if (drivers == null || drivers.equals("")) {
return;
}
String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
println("DriverManager.Initialize: loading " + aDriver);
//在这里需要加载各个厂商实现的数据库驱动,如com.mysql.Driver
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}
如果出现SPI相关代码时,我们应该如何解决基础类去加载用户代码类呢?
这个时候,JVM不得不妥协,推出线程上下文类加载器的概念,去解决该问题。这样也就打破了双亲委派
线程上下文类加载器:(ThreadContextClassLoader)
设置线程上下文类加载器源码
public Launcher() {
// Create the extension class loader
ExtClassLoader var1;
try {
//扩展类加载器
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
// Now create the class loader to use to launch the application
try {
// 应用类加载器/系统类加载器
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
// 线程上下文类加载器
// Also set the context class loader for the primordial thread
Thread.currentThread().setContextClassLoader(this.loader);
// Finally, install a security manager if requested
String var2 = System.getProperty("java.security.manager");
if (var2 != null) {
SecurityManager var3 = null;
if (!"".equals(var2) && !"default".equals(var2)) {
try {
var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
} catch (IllegalAccessException var5) {
} catch (InstantiationException var6) {
} catch (ClassNotFoundException var7) {
} catch (ClassCastException var8) {
}
} else {
var3 = new SecurityManager();
}
if (var3 == null) {
throw new InternalError("Could not create SecurityManager: " + var2);
}
System.setSecurityManager(var3);
}
}
获取线程上下文类加载器源码
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
第三种方式
为了满足热部署、不停机更新需求。OSGI 就是利用自定义的类加载器机制来完成模块化热部署,而它实现的类加载机制就没有完全遵循自下而上的委托,有很多平级之间的类加载器查找,具体就不展开了,有兴趣可以自行研究一下。
运行时数据区
整个JVM构成里面,由三部分组成:类加载系统、运行时数据区、执行引擎
按照线程使用情况和职责分成两大类
- 线程独享 (程序执行区域)
- 不需要垃圾回收
- 虚拟机栈、本地方法栈、程序计数器
- 线程共享 (数据存储区域)
- 垃圾回收
- 存储类的静态数据和对象数据
- 堆和方法区
堆
Java堆在JVM启动时创建内存区域去实现对象、数组与运行时常量的内存分配,它是虚拟机管理最大的,也是垃圾回收的主要内存区域 。
内存划分:
核心逻辑就是三大假说,基于程序运行情况进行不断的优化设计。
堆内存为什么会存在新生代和老年代?
分代收集理论:当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational
Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法
则,它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
- 如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;
- 如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域。
这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
为什么新生代里面需要有两个Survivor区域呢?
避免系统做垃圾回收时卡顿和提升垃圾回收的效率
内存模型变迁:
- Young 年轻区 :主要保存年轻对象,分为三部分,Eden区、两个Survivor区。
- Tenured 年老区 :主要保存年长对象,当对象在Young复制转移一定的次数后,对象就会被转移到Tenured区。
- Perm 永久区 :主要保存class、method、filed对象,这部份的空间一般不会溢出,除非一次性加载了很多的类,不过在涉及到热部署的应用服务器的时候,有时候会遇到OOM :PermGen space 的错误。
- Virtual区: 最大内存和初始内存的差值,就是Virtual区
- 由2部分组成,新生代(Eden + 2Survivor ) + 年老代(OldGen )
- JDK1.8中变化最大是,的Perm永久区用Metaspace进行了替换
- 注意:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中。区别于JDK1.7
- JDK1.9
- 取消新生代、老年代的物理划分
- 将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的新生代、老年代区域
储物收纳
内存信息案例:
package com.hero.jvm.memory;
/
-Xms100m -Xmx100m
/
public class HeapDemo {
public static void main(String[] args) {
System.out.println("======start=========");
try {
Thread.sleep(1000000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("========end=========");
}
}
JDK6堆内存结构
C:\develop\java\jdk1.6.0_45\bin\javac HeapDemo.java
C:\develop\java\jdk1.6.0_45\bin\java -Xms100m -Xmx100m HeapDemo
C:\develop\java\jdk1.6.0_45\bin\jmap -heap 3612
JDK7堆内存结构
C:\develop\java\jdk1.7.0_80\bin\javac HeapDemo.java
C:\develop\java\jdk1.7.0_80\bin\java -Xms100m -Xmx100m HeapDemo
C:\develop\java\jdk1.7.0_80\bin\jmap -heap 10420
JDK8堆内存结构
C:\develop\java\jdk1.8.0_251\bin\javac HeapDemo.java
C:\develop\java\jdk1.8.0_251\bin\java -Xms100m -Xmx100m HeapDemo
C:\develop\java\jdk1.8.0_251\bin\jmap -heap 18276
JDK11堆内存结构
C:\develop\java\jdk-11.0.7\bin\javac HeapDemo.java
C:\develop\java\jdk-11.0.7\bin\java -Xms100m -Xmx100m HeapDemo
C:\develop\java\jdk-11.0.7\bin\jhsdb jmap --heap --pid 19380
虚拟机栈
1)栈帧是什么?
栈帧(Stack Frame)是用于支持虚拟机进行方法执行的数据结构。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
栈内存为线程私有的空间,每个线程都会创建私有的栈内存,生命周期与线程相同,每个Java方法在执行的时候都会创建一个栈帧(Stack Frame)。栈内存大小决定了方法调用的深度,栈内存过小则会导致方法调用的深度较小,如递归调用的次数较少。
2)当前栈帧
一个线程中方法的调用链可能会很长,所以会有很多栈帧。只有位于JVM虚拟机栈栈顶的元素才是有效
的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法,定义这个方法的类叫做当前类。
执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前
方法执行结束,那这个方法的栈帧就不再是当前栈帧了。
3)什么时候创建栈帧
调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈
帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢
弃此栈帧。
4)栈异常的两种情况:
如果线程请求的栈深度大于虚拟机所允许的深度(Xss默认1m),会抛出StackOverflowError异常
如果在创建新的线程时,没有足够的内存去创建对应的虚拟机栈,会抛出OutOfMemoryError异常
【不一定】
5)栈异常案例:
如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出 StackOverflowError 异常(-Xss);
package com.hero.jvm.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();
}
}
}
C:\develop\java\jdk1.8.0_251\bin\javac StackErrorMock.java
C:\develop\java\jdk1.8.0_251\bin\java -Xss1m StackErrorMock
C:\develop\java\jdk1.8.0_251\bin\java -Xss256k StackErrorMock
补充案例:用来演示大量创建线程撑爆内存会发生什么!
思考题:如果创建海量线程线程的时候,同时每个线程疯狂递归,请问到底是先OOM还是StackOverflowError?
public class TestThread {
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
new Thread("Thread-" + i) {
@Override
public void run() {
try {
String name = Thread.currentThread().getName();
System.out.println(name);
recurive(30000);
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
}
System.out.println();
}
}.start();
}
}
public static void recurive(double d){
if (d ==0)
return;
recurive(d - 1);
}
}
本地方法栈
本地方法栈和虚拟机栈相似,区别就是虚拟机栈为虚拟机执行Java服务(字节码服务),而本地方法栈为虚拟机使用到的Native方法(比如C++方法)服务。
简单地讲,一个Native Method就是一个Java调用非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方法调用cpp编写的接口!多线程底层就是这么实现的,在多线程部分我们会看一下Thread实现的源码,到时候就可以理解了。
方法区
方法区(Method Area)是可供各个线程共享的运行时内存区域,方法区本质上是Java语言编译后代码存储区域,它存储每一个类的结构信息,例如:运行时常量池、成员变量、方法数据、构造方法和普通方法的字节码指令等内容。很多语言都有类似区域。
方法区的具体实现有两种:永久代(PermGen)、元空间(Metaspace)
1)方法区存储什么数据?
主要有如下三种类型
- 第一:Class
- 类型信息,比如Class(com.hero.User类)
- 方法信息,比如Method(方法名称、方法参数列表、方法返回值信息)
- 字段信息,比如Field(字段类型,字段名称需要特殊设置才能保存的住)
- 类变量(静态变量):JDK1.7之后,转移到堆中存储
- 方法表(方法调用的时候) 在A类的main方法中去调用B类的method1方法,是根据B类的方法表去查找合适的方法,进行调用的。
- 第二:运行时常量池(字符串常量池):从class中的常量池加载而来,JDK1.7之后,转移到堆中存储
- 字面量类型
- 引用类型–>内存地址
- 第三:JIT编译器编译之后的代码缓存
如果需要访问方法区中类的其他信息,都必须先获得Class对象,才能取访问该Class对象关联的方法信息或者字段信息。
2)永久代和元空间的区别是什么?
- JDK1.8之前使用的方法区实现是永久代,JDK1.8及以后使用的方法区实现是元空间。
- 存储位置不同:
- 永久代所使用的内存区域是JVM进程所使用的区域,它的大小受整个JVM的大小所限制。
- 元空间所使用的内存区域是物理内存区域。那么元空间的使用大小只会受物理内存大小的限制。
- 存储内容不同:
- 永久代存储的信息基本上就是上面方法区存储内容中的数据。
- 元空间只存储类的元信息,而静态变量和运行时常量池都挪到堆中。
3)为什么要使用元空间来替换永久代?
字符串存在永久代中,容易出现性能问题和永久代内存溢出。
类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
Oracle 计划将HotSpot 与 JRockit 合二为一。
方法区实现变迁历史:
移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了JavaHeap。但永久代仍存在于JDK1.7中,并没完全移除,譬如:字面量转移到了java heap;类的静态变量(class statics)转移到了java heap。
4)字符串OOM异常案例
案例代码
以下这段程序以2的指数级不断的生成新的字符串,这样可以比较快速的消耗内存:
package com.hero.jvm.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
C:\develop\java\jdk1.6.0_45\bin\javac StringOomMock.java
C:\develop\java\jdk1.6.0_45\bin\java -XX:PermSize=8m -XX:MaxPermSize=8m -Xmx16m StringOomMock
JDK 1.6 的运行结果:
在JDK 1.6下,会出现永久代的内存溢出。
JDK1.7
C:\develop\java\jdk1.7.0_80\bin\javac StringOomMock.java
C:\develop\java\jdk1.7.0_80\bin\java -XX:PermSize=8m -XX:MaxPermSize=8m -Xmx16m StringOomMock
JDK 1.7的运行结果:
在JDK 1.7中,会出现堆内存溢出
结论是:JDK 1.7 已经将字符串常量由永久代转移到堆中。
JDK1.8
C:\develop\java\jdk1.8.0_251\bin\javac StringOomMock.java
C:\develop\java\jdk1.8.0_251\bin\java -XX:PermSize=8m -XX:MaxPermSize=8m -Xmx16m StringOomMock
JDK 1.8的运行结果:
在JDK 1.8 中,也会出现堆内存溢出,并且显示 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。
结论是:可以验证 JDK 1.8 中已经不存在永久代的结论。
字符串常量池
1)三种常量池的比较
- class常量池:一个class文件只有一个class常量池
- 字面量:数值型(int、float、long、double)、双引号引起来的字符串值等
- 符号引用:Class、Method、Field等
- 运行时常量池:一个class对象有一个运行时常量池
- 字面量:数值型(int、float、long、double)、双引号引起来的字符串值等
- 符号引用:Class、Method、Field等
- 字符串常量池:全局只有一个字符串常量池
- 双引号引起来的字符串值
2)字符串常量池如何存储数据?
为了提高匹配速度, 即更快的查找某个字符串是否存在于常量池 Java 在设计字符串常量池的时候,还搞了一张StringTable, StringTable里面保存了字符串的引用。StringTable类似于HashTable(哈希表)。在JDK1.7+,StringTable可以通过参数指定 -XX:StringTableSize=99991
什么是哈希表呢?
哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
哈希表本质上是一个数组+链表
目的 : 为了加快数据查找的速度。
存在问题:hash冲突问题,一旦出现冲突,那么就会形成链表,链表的特点是增删快,但查询慢。
数组下标计算公式:hash(字符串) % 数组长度
数组中存储的是Entry,通过指针next形成链表
HashMap<String, Integer> map = new HashMap<>();
map.put("hello", 53);
map.put("world", 35);
map.put("java", 55);
map.put("world", 52);
map.put("通话", 51);
map.put("重地", 55);
3)字符串常量池如何查找字符串:
- 根据字符串的hashcode找到对应entry
- 如果没有冲突,它可能只是一个entry
- 如何有冲突,它可能是一个entry的链表,然后Java再遍历链表,匹配引用对应的字符串
- 如果找到字符串,返回引用
- 如果找不到字符串,在使用intern()方法的时候,会将intern()方法调用者的引用放入到stringtable中
4)字符串常量池案例
public class StringTableDemo {
public static void main(String[] args) {
HashMap<String, Integer> map = new HashMap<>();
map.put("hello", 53);
map.put("world", 35);
map.put("java", 55);
map.put("world", 52);
map.put("通话", 51);
map.put("重地", 55);
//出现哈希冲突怎么办?
//System.out.println("map = " + map);//
test();
}
public static void test() {
String str1 = "abc";
String str2 = new String("abc");
System.out.println(str1 == str2);//false
String str3 = new String("abc");
System.out.println(str3 == str2);//false
String str4 = "a" + "b";
System.out.println(str4 == "ab");//true
String s1 = "a";
String s2 = "b";
String str6 = s1 + s2;
System.out.println(str6 == "ab");//false
String str7 = "abc".substring(0,2);
System.out.println(str7 == "ab");//false
String str8 = "abc".toUpperCase();
System.out.println(str8 == "ABC");//false
String s5 = "a";
String s6 = "abc";
String s7 = s5 + "bc";
System.out.println(s6 == s7.intern());//true
}
}
总结:
- 单独使用””引号创建的字符串都是常量,编译期就已经确定存储到String Pool中。
- 使用new String(“”)创建的对象会存储到heap中,是运行期新创建的。
- 使用只包含常量的字符串连接符如”aa”+”bb”创建的也是常量,编译期就能确定已经存储到StringPool中。
- 使用包含变量的字符串连接如”aa”+s创建的对象是运行期才创建的,存储到heap中。
- 运行期调用String的intern()方法可以向String Pool中动态添加对象。
程序计数器
程序计数器(Program Counter Register),也叫PC寄存器,是一块较小的内存空间,它可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程回复等都需要依赖这个计数器来完成。
为什么需要程序计数器?
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(针对多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换(系统上下文切换)后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
存储的什么数据?
如果一个线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
如果正在执行的是一个Native方法,这个计数器的值则为空。
异常:此内存区域是唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError异常情况的区域。
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
本机直接内存的分配不会受到Java 堆大小的限制,受到本机总内存大小限制。
直接内存(堆外内存)与堆内存比较:
- 直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
- 直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显
直接内存案例:
package com.hero.jvm.memory;
import java.nio.ByteBuffer;
public class ByteBufferCompare {
public static void main(String[] args) {
//allocateCompare(); //分配比较
operateCompare(); //读写比较
}
/
直接内存 和 堆内存的 分配空间比较
结论: 在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题
/
public static void allocateCompare() {
int time = 1000 10000; //操作次数,1千万
long st = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
//ByteBuffer.allocate(int capacity) 分配一个新的字节缓冲区。
ByteBuffer buffer = ByteBuffer.allocate(2); //非直接内存分配申请
}
long et = System.currentTimeMillis();
System.out.println("在进行" + time + "次分配操作时,堆内存 分配耗时:" +(et - st) + "ms");
long st_heap = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
//ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
ByteBuffer buffer = ByteBuffer.allocateDirect(2); //直接内存分配申请
}
long et_direct = System.currentTimeMillis();
System.out.println("在进行" + time + "次分配操作时,直接内存 分配耗时:" +(et_direct - st_heap) + "ms");
}
/
直接内存 和 堆内存的 读写性能比较
结论:直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
/
public static void operateCompare() {
int time = 10 10000 10000; //操作次数,10亿
ByteBuffer buffer = ByteBuffer.allocate(2 time);
long st = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
// putChar(char value) 用来写入 char 值的相对 put 方法
buffer.putChar('a');
}
buffer.flip();
for (int i = 0; i < time; i++) {
buffer.getChar();
}
long et = System.currentTimeMillis();
System.out.println("在进行" + time + "次读写操作时,非直接内存读写耗时:" +(et - st) + "ms");
ByteBuffer buffer_d = ByteBuffer.allocateDirect(2 time);
long st_direct = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
// putChar(char value) 用来写入 char 值的相对 put 方法
buffer_d.putChar('a');
}
buffer_d.flip();
for (int i = 0; i < time; i++) {
buffer_d.getChar();
}
long et_direct = System.currentTimeMillis();
System.out.println("在进行" + time + "次读写操作时,直接内存读写耗时:" +(et_direct - st_direct) + "ms");
}
}
输出:
- 在进行10000000次分配操作时,堆内存 分配耗时:82ms
- 在进行10000000次分配操作时,直接内存 分配耗时:6817ms
- 在进行1000000000次读写操作时,堆内存 读写耗时:1137ms
- 在进行1000000000次读写操作时,直接内存 读写耗时:512ms
为什么会是这样?
从数据流的角度,来看
- 非直接内存作用链:本地IO –>直接内存–>非直接内存–>直接内存–>本地IO
- 直接内存作用链:本地IO–>直接内存–>本地IO
直接内存的使用场景:
- 有很大的数据需要存储,它的生命周期很长
- 适合频繁的IO操作,例如:网络并发场景