啰里吧嗦jvm

一.为什么要了解jvm

有次做项目的时候,程序run起来的时候,总是报OutOfMemoryError,有老司机教我们用jconsole.exe看内存溢出问题

就是这货
启动jconsole后,发现一个是进程是它自己,一个是我的eclipse,哈哈,现在还不习惯idea, 还一个就是我们项目启动的进程了,项目我用的maven搭建的,引入了tomcat插件,

jconsole可以看很多东西, 需要大家慢慢摸索,包括可以检测死锁,当时的项目启动程序,就看堆内存占用的空间不断上升,最后报错,

当时银行的jvm内存设置已经比较大了,最后发现启动时日志打印不合理,打印的东西太多,占用内存空间太大

了解jvm可以让我们排查问题多出一个角度,而不是每次都是重启解决问题,当然一般情况下都是逻辑代码写的不好,产生了大量的内存,导致堆不够用发生

jvm内存分为 堆 和 非堆
堆又分为:

1.老年代
Old Gen:年轻代的对象如果能够挺过数次收集,就会进入老人区

2.新生代
Eden Space(伊甸园):一个Eden,所有新建对象都会存在于该区
Survivor Space(幸存者区):两个Survivor区,用来实施复制算法

3.持久代 
permanent:装载Class信息等基础数据,默认64M,一般情况下,持久代是不会进行GC的
-XX:MaxPermSize=
  
-Xms 是堆的初始内存
-Xmx 是堆的最大内存
-Xmn 年轻代大小
-Xss 每个线程的堆栈大小

通过java -X 命令可查看

java –Xms128m –Xmx128m 

 
 

非堆分为

Metaspace

Code Cache

Compressed Class Space

 

二.当你输入java Hello时发生了什么

现在我们当然使用了各种集成的开发工具,比如eclipse,

最原始的执行java程序,

首先你得先按照好jdk,然后配置好环境变量, 让系统能在任意目录下都能找到java命令 并执行

其次写一个java文件,符合规范,比如类名和文件名一致,类要是public的,javac会对其进行校验

通过cmd打开windows的命令窗口, 使用javac xxx.java 命令将其编译成字节码文件, java虚拟机只认字节码文件,无论你是通过什么途径得到它的,只要符合字节码文件规范,就能在不同系统上的jvm运行,我们开发通常在windows环境, 然后部署项目在linux环境,只要一次编译,到处运行

执行java xxx 命令,这里jvm究竟做了什么呢

     String sourcePath = "C:/Users/"+System.getProperties().getProperty("user.name")+"/Desktop";
        
        JavaFile javaFile = JavaFile.builder("proxy", typeSpecBuilder.build()).build();
        // 为了看的更清楚,我将源码文件生成到桌面
       
        javaFile.writeTo(new File(sourcePath));
 
        // 编译
        JavaCompiler.compile(new File(sourcePath + "/proxy/Proxy.java"));
 
        // 使用反射load到内存
        URLClassLoader classLoader = new URLClassLoader(new URL[] { new URL("file:C:\\Users\\"+System.getProperties().getProperty("user.name")+"\\Desktop\\") });

        Object obj = null;
        
        //Classloader:类加载器,你可以使用自定义的类加载器,我们的实现版本为了简化,直接在代码中写死了Classloader。
        
        Class clazz1 = classLoader.loadClass("proxy.Proxy");
        
        System.out.println(clazz1);
        System.out.println(clazz1.getDeclaredConstructors().getClass());
        
        //将生成的TimeProxy编译成class 使用类加载器加载进内存中 再通过反射或者该类的构造器 
        //再通过构造器将其代理类 TimeProxy 构造出来
        
        //NoSuchException  打印classz信息 发现 刚开始创建类 没有使用public 
        Constructor constructor = clazz1.getConstructor(InvocationHandler.class);
        System.out.println("constructor" + constructor);
        obj = constructor.newInstance(handler);

 报错那就是少引了一个jar包

<dependency>
<groupId>com.squareup</groupId>
<artifactId>javapoet</artifactId>
<version>1.8.0</version>
</dependency>

首先根据内存配置,为jvm申请内存空间,并按照 jvm的 【内存模型】  划分好 区域

创建一个引导类加载器实例,初步加载系统类到内存方法区区域中;

创建JVM 启动器实例Launcher

并取得类加载器ClassLoader

使用上述获取的ClassLoader实例加载我们定义的类

加载完成时候JVM会执行Main类的main方法入口,执行Main类的main方法结束,java程序运行结束,JVM销毁

上面的看看就行了,说的很笼统,基本流程知道就ok,就是jvm里面也是各种代码实现的,有用到c++,下图是看程序jvm内存大小

我的电脑 默认是128M 和 1.75G

三.jvm内存模型

 3.1 运行时区域

xxx.java文件通过 JavaCompiler java编译器 编译成xxx.class文件----

执行java命令其实就启动了java.exe,启动一个Java程序时,一个JVM实例就产生了

JVM实例对应了一个独立运行的java程序它是进程级别------

jvm实例创建启动器获得类加载器

classLoader,加载.class文件进内存

由谁来执行,由执行引擎来执行, 执行引擎可以通过解释器 或者 即时编译器 去执行指令

反汇编命令

javap -c xxx Java 字节码的指令

在JVM实现中,线程为Execution Engine的一个实例,main函数是JVM指令执行的起点

JVM会创建main线程来执行main函数,以触发JVM一系列指令的执行

classLoader,加载.class文件进内存可以细分为3步骤

3.1.1 加载 xxx.class是一个二进制字节码文件, 加载到method are方法区

3.1.2 链接 可以分为3步

3.1.2.1 验证 验证.class文件在结构上满足JVM规范的要求,验证工作可能会引起其他类的加载但不进行验证和准备

3.1.2.2 准备 正式为类变量分配内存并设置类变量初始值的阶段,,“通常情况”下是数据类型的零值,特殊情况就是你定义了是static final ,那么jvm会识别为常量,为该变量设置一个ConstantValue属性,准备阶段就初始化

3.1.2.3 解析 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程

3.1.2.4 初始化 简单来说真正开始赋值, 收集类变量 , 静态块, 执行类构造器,虚拟机也规定了5种情况需要对类做个初始化, 例如初始化子类发现父类还没初始化过, 那么先初始化父类, 每个类在初始化前 ,前面的加载链接都是必须执行的,每个类只被初始化一次

初始化还有很多规则,比如使用数组定义来引用类, 不会触发该类的初始化, DemoTest[] a = new DemoTest[10],假如DemoTest里有类变量,静态块,则不会初始化

public class ClassLoadTest {
    
    public static void main(String[] args) {
        SuperCla[]  a = new SuperCla[11];
     }
}

class SuperCla{
static {
        
        System.out.println("xxxxx");
    }
}
执行结果是不输出任何结果
例如子类引用定义在父类的静态变量,则不会触发子类的初始化
例如编译阶段就有常量属性的,就是那种final static ,就不会触发类的初始化, 因为直接从常量池中取

一个经典的例子

public class ClassLoadTest {
    
    public static void main(String[] args) {
    
     }
    static {
        
        System.out.println("1");
    }
    static ClassLoadTest a = new ClassLoadTest();
    
    static {
        
        System.out.println("2");
    }
    
    {
        System.out.println("6");
    }
    
    ClassLoadTest(){
        System.out.println("3");
        System.out.println("b=" + b + ",c =" + c + ",d = " + d+ ",e=" + e);
    }
    int b = 100;
    
    static int c = 200;
    
    static final int d = 300;
    
    static int e = 400;
    
    static void func() {
        System.out.println("第二次c=" + c );
        System.out.println("4");
    }
    
}

1
6
3
b=100,c =0,d = 300,e=0
2

 

所以归到该类上就是 java ClassLoadTest 命令

jvm 加载 ClassLoadTest 类

|

验证 没毛病

|

准备 类变量要分配内存 并设置初始值了 里面有3个类变量 static ClassLoadTest a = new ClassLoadTest();

static int c

static final int d

a呢 就初始值就是null c内初始值是0 ,因为这个通常情况下是数据类型的零值 意外情况就是加了final关键字 所以会为d生成一个ConstantValue属性,该属性值是300

|

本来准备完了是要继续初始化的 ,但是由于 类变量的初始化是它自身 实例的初始化 有new 关键字

所以 就先初始化 该类的实例了,

先执行非静态块 ,再执行构造方法

类的初始化就直接中断掉了....

所以先打印出来的 c 是0

(如果你在func里 打印c的值 会发现c还是200)

|

当前面的加载、链接、初始化都结束后,JVM 会调用 ClassLoadTest 的 main 方法。被调用的 main 必须被修饰为 public , static, void

|

按照源码的顺序 执行静态代码块 和 类变量 ---&gt; 先打印1 ---&gt;然后就跑偏了 静态变量new了自己的一个实例 于是去初始化自己的实例---&gt;

先执行 非静态块 打印6-----&gt; 再执行构造方法----&gt;打印3----&gt;打印bcd变量的值 此时 b是实例变量,成语变量 new ClassLoadTest()进行初始化,所以是100

c 是类变量,由于前面准备阶段---先去执行了 ClassLoadTest a = new ClassLoadTest(); c还是零值 ,d由于是final static, 准备阶段就默认给个值

a= null b=100 c=0 d=300---------------

在按照源码顺序执行 第二个静态代码块 打印--2


具体的类加载流程我们知道了, 现在来看看类加载到jvm内存后, 把 哪些东西 放在内存模型的 哪个位置

首先来看线程共享的区域: 方法区 和 堆

 1.先看方法区 method area , 存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量、(即时)编译器编译后的代码,

Class文件中还有常量池,存储编译期间生成的字面量和符号引用,

而方法区里面的【运行时常量池】就是该类或接口的运行时表示

在这里要介绍下String 的 intern方法, 该方法在运行期间也可将新的常量 放进常量池, 大大减小堆的空间

new String(“abc”).intern()

abc 会出现在堆里是一个新的对象, 调用intern方法后, 会把abc 放在常量池中

并返回指向该常量的引用

intern用来返回常量池中的某字符串,如果常量池中已经存在该字符串,则直接返回常量池中该对象的引用。
否则,在常量池中加入该对象,然后 返回引用。
原文举例:
Now lets understand how Java handles these strings. When you create two string literals:

String name1 = "Ram"; 

String name2 = "Ram";

In this case, JVM searches String constant pool for value "Ram", and if it does not find it there 
then it allocates a new memory space and store value "Ram" and return its reference to name1. 
Similarly, for name2 it checks String constant pool for value "Ram" but this time it find "Ram" 
there so it does nothing simply return the reference to name2 variable. 
The way how java handles only one copy of distinct string is called String interning.


举例
String s1 = new String("aaa").intern();
String s2 = "aaa";
System.out.println(s1 == s2);    // true

2.堆: 存储对象及数组本身, 而非引用,是垃圾收集器管理的主要区域

3.接下来就是每个线程独有的了,本地方法栈,jvm栈, 在HotSpot中,没有JVM Stacks和Native Method Stacks之分,功能上已经合并,所以放在一起介绍,原理都类似

JVM 栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,所以很容易想到局部变量肯定在栈帧中,因为方法里定义的是局部变量,还有些其他的看图

栈的使用是比如 线程执行test方法,就会创建test的栈帧,并将此栈帧压栈,发现inner()方法,又创建一个栈帧,压栈, 等inner()
方法执行完毕,则将inner()出栈,在将test()出栈
public class A {
        void test() {
                inner();
        }
        
        void inner(){
        }

 4.最后一个 程序计数器

在JVM中,多线程是通过线程轮流切换来获得CPU的执行时间, 就是说在微观上,线程都是走走停停, 在某一个时刻一个cpu的内核只会执行 某一条线程的指令, 那么需要一个工具记录之前线程执行到哪儿了,只是打个比方 A线程执行到3这个指令的位置停住了, 把cpu让给线程B, 线程B执行完毕后, A线程怎么知道刚才执行到哪儿了呢

那么只能是用一个 每个线程独有的 程序计算器 来记住A线程---位置3 ,这个也很好理解这个占用内存大小是固定的
public void test();
    Code:
       0: new           #17                 // class java/lang/String
       3: dup
       4: ldc           #19                 // String 1
       6: invokespecial #21                 // Method java/lang/String."&lt;init&gt;":
       (Ljava/lang/String;)V
       9: astore_1
      10: aload_1
      11: invokevirtual #24  

四.GC垃圾回收

首先我们要确定GC垃圾回收主要作用的区域, 由于 jvm栈,本地方法栈,程序计数器都是随线程生灭的,所以gc主要是对方法区和堆里面的内容进行回收

而堆又是占内存最大的一块区域,存放对象实例,所以也是回收重点。

这些知识有点抽象,所以我们希望很直观的看到, 用 cmd 命令行执行class文件的时候 指定参数也可以看,不过我们现在都是用eclipse

选择xx.java --右击---Run as -&gt; Run configurations

- java应用名 -arguments -VM arguments,加入jvm参数

-Xms20m --jvm堆的最小值  
-Xmx20m --jvm堆的最大值  
-XX:+PrintGCTimeStamps -- 打印出GC的时间信息  
-XX:+PrintGCDetails  --打印出GC的详细信息  
-verbose:gc --开启gc日志  
-Xloggc:d:/gc.log -- gc日志的存放位置  
-Xmn10M -- 新生代内存区域的大小  

-XX:SurvivorRatio=8 --新生代内存区域中Eden和Survivor的比例

确保设置的是自己要测试的类, 不是的话先执行一遍

那具体采取什么措施去回收这些对象呢,针对方法区的变量,类信息回收, 某些虚拟机的永久代, 条件比较苛刻, 而且占用空间小, 而且可以通过配置决定,在这里不过多讨论


回收的判定-对象死不死, 回收的时机--死了何时处理, 回收的方法策略--火化还是土葬

1.回收的判定: 针对堆的对象实例进行回收,先判断这些对象有没有用, 能不能被回收

有两种算法

Java不使用的引用计数算法
:当对象有个地方引用它,计数器+1, 引用失效计数器-1, 计数器为0则判定为不可使用对象

 缺点:无法解决对象互相 循环引用

public class Test {
    public Test test; 
    
    public static void main(String[] args){
        Test t1 = new Test();
        Test t2 = new Test();
        t1.test = t2;
        t2.test = t1;
        
        System.gc();
    }
    
}
2.可达性分析算法(根搜索算法):

这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,

 
 

从这些节点向下搜索,搜索所走过的路径称为引用链,

 
 

当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。

在Java语言中,可以作为GCRoots的对象包括下面几种:

(1). 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。

(2). 方法区中的类静态属性引用的对象。

(3). 方法区中常量引用的对象。

(4). 本地方法栈中JNI(Native方法)引用的对象。

 

 8,9,10会被回收

2. 回收的时机:

即使是被判断不可达的对象,也要再进行筛选,当对象没有覆盖finalize()方法,

或者finalize方法已经被虚拟机调用过,则没有必要执行;

如果有必要执行——放置在F-Queue的队列中——Finalizer线程执行。

注意:对象可以在被GC时可以自我拯救(this),机会只有一次,因为任何一个对象的finalize()方法都只会被系统自动调用一次

。并不建议使用,应该避免。使用try_finaly或者其他方式。

这个只是判断哪些对象存活,哪些对象死亡,并不真正进行回收, 而且即使被判定为不可达对象, 回不回收还是要经过两次标记(了解)

1. 如果对象在进行可达性分析后发现没有与GCRoots相连的引用链,

(1)该对象被第一次标记并进行一次筛选

筛选条件为是否有必要执行该对象的finalize方法,若对象没有覆盖finalize方法或者该finalize方法是否已经被虚拟机执行过了

,则均视作不必要执行该对象的finalize方法,即该对象将会被回收。

反之,若对象覆盖了finalize方法并且该finalize方法并没有被执行过,那么,这个对象会被放置在一个叫F-Queue的队列中,

之后会由虚拟机自动建立的、优先级低的Finalizer线程去执行,而虚拟机不必要等待该线程执行结束,即虚拟机只负责建立线程,

其他的事情交给此线程去处理。

(2).对F-Queue中对象进行第二次标记,如果对象在finalize方法中拯救了自己,即关联上了GCRoots引用链,

如把this关键字赋值给其他变量,那么在第二次标记的时候该对象将从“即将回收”的集合中移除,如果对象还是没有拯救自己,

那就会被回收。如下代码演示了一个对象如何在finalize方法中拯救了自己,然而,它只能拯救自己一次,第二次就被回收了。

package testGc;

/*
 * 此代码演示了两点:
 * 1.对象可以再被GC时自我拯救
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 * */
public class FinalizeEscapeGC {
    
    public String name;
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public FinalizeEscapeGC(String name) {
        this.name = name;
    }

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }
    
    
    //如果判断为该对象不可达 
    
    //第一次标记 
    
    //对象没有覆盖finalize方法或者该finalize方法是否已经被虚拟机执行过了
    //则均视作不必要执行该对象的finalize方法,即该对象将会被回收
    
    //此FinalizeEscapeGC对象覆盖了finalize()  且该finalize方法并没有被执行过
    //这个对象会被放置在一个叫F-Queue的队列中 
    //之后会由虚拟机自动建立的、优先级低的Finalizer线程去执行
    
    //第二次标记
    
    //关联上了GCRoots引用链则该对象将从“即将回收”的集合中移除
    //否则会被回收
    
    //只能第一次执行的时候可以拯救自己一次 不被回收
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        System.out.println(this);
        FinalizeEscapeGC.SAVE_HOOK = this;
        //关联上了GCRoots引用链
        //把this关键字赋值给其他变量
        //方法区中的类静态属性引用的对象
    }

    @Override
    public String toString() {
        return name;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC("leesf");
        System.out.println(SAVE_HOOK);
        // 对象第一次拯救自己
        SAVE_HOOK = null;
        System.out.println(SAVE_HOOK);
        
        // 两次标记  1.重写了finalize方法且没有执行过 放队列  
        // 2.队列中坚持是不是有 关联上引用链  有即可以拯救自己一次 
        System.gc();
        
        // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead : (");
        }

        // 下面这段代码与上面的完全相同,但是这一次自救却失败了
        // 一个对象的finalize方法只会被调用一次
        SAVE_HOOK = null;
        System.gc();
        // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead : (");
        }
    }
}

重点就是finalize方法只会执行一次,只有第一次可以拯救自己

3.而是通过垃圾收集算法和垃圾收集器来具体执行回收,

垃圾收集器是 垃圾收集算法的具体实现

1、标记-清除(Mark-Sweep)算法
首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象

效率低

2、复制(Copying)算法
它将可用的内存分为两块,每次只用其中一块,当这一块内存用完了
就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉

分代收集算法就把内存划分为几块, 通常在新生代用复制算法
【新生代
Eden Space(伊甸园):一个Eden,所有新建对象都会存在于该区
Survivor Space(幸存者区):两个Survivor区,用来实施复制算法】

如果采取1:1, 那非常不划算,意味着可用内存只能用一半,通常新生代的内存 Eden + Survivor 占90%, Survivor空闲占10%,
回收就 先复制  eden和survivor活着的对象到  Survivor, 再清理调eden 和 survivor

  
(HotSpot虚拟机默认Eden区和Survivor区的比例为8:1)

3、标记-整理(Mark-Compact)算法

分代收集算法就把内存划分为几块, 通常在老年代用标记整理法
因为老年代通常都是不易被回收的对象, 采用复制算法非常不划算, 存活90%, 复制后还是90%

让所有存活对象都向一端移动,然后直接清理掉边界以外的内存

4、分代收集算法
以上内容的结合, 根据对象的生命周期,内存划分 新生代, 老年代, 
大批对象死去、少量对象存活的(新生代),使用复制算法
对象存活率高、没有额外空间进行分配担保的(老年代)采用其他两个方法</pre><figure><hr/></figure><h2>五.JVM性能调优</h2><pre class="prism-token token language-js">通过上面的一些理论, 具体的实践还是在项目中就具体情况进行分析, 当然99%的oom 都是代码问题导致的,
比如打印日志占用的内存空间不足,
比如某个线程产生大量对象缺没有被释放

少量是设置原因,比如最大堆内存设置的太小,
比如卡顿是因为频繁发生FUll GC, 那么如何调整老年代和新生代的大小

五.JVM性能调优

 通过上面的一些理论, 具体的实践还是在项目中就具体情况进行分析, 当然99%的oom 都是代码问题导致的,\n比如打印日志占用的内存空间不足,\n比如某个线程产生大量对象缺没有被释放\n\n少量是设置原因,比如最大堆内存设置的太小,\n比如卡顿是因为频繁发生FUll GC, 那么如何调整老年代和新生代的大小

六.守护线程

任何非守护线程还在运行,程序就不会终止, 在所有用户线程都终止了, 那么守护线程也终止,虚拟机退出

最典型的应用就是GC(垃圾回收器)

猜你喜欢

转载自www.cnblogs.com/tom-kang/p/10694604.html