JVM理解

1、JVM的基本介绍

JVM,即 Java Virtual Machine ,是Java 程序的运行环境(Java 二进制字节码的运行环境)。

JVM的作用:

  • 一次编写,到处运行
  • 自动内存管理,垃圾回收功能
  • 数组下标越界检查
  • 多态

 

1.1、JVM、JRE、JDK三者的比较

JVM、JRE、JDK 的关系如下图所示。

  • JDK(Java Development Kit):java开发工具包,在JRE的基础上增加编译工具,如javac
  • JRE(Java Runtime Environment):java的运行时环境,在JVM的基础上结合一些基础类库
  • JVM:java虚拟机, 可以屏蔽java代码与底层虚拟机之间的关系

 

1.2、常见的JVM

 

1.3、JVM的整体架构

 

2、程序计数器

Program Counter Register 程序计数器(寄存器),作用是记住下一条jvm指令的执行地址。
在物理上实现程序计数器是通过一个叫寄存器来实现的,寄存器可以说是整个CPU组件里读取速度最快的一个单元,因为读取/写指令地址这个动作是非常频繁的。所以Java虚拟机在设计的时候就把CPU中的寄存器当做了程序计数器,用他来存储地址,将来去读取这个地址。
程序计数器的特点:
  • 是线程私有的。每个线程都有自己的程序计数器,随着线程创建而创建,随线程销毁而销毁
  • 不会存在内存溢出

 

3、虚拟机栈(线程内存)

 

3.1、虚拟机栈基本介绍

Java Virtual Machine Stacks (Java 虚拟机栈),每个线程运行时所需要的内存,称为虚拟机栈

每个栈由多个栈帧(Frame)组成,对应着该线程内各个方法调用时所占用的内存,即线程内每个方法的调用都会创建一个新的栈帧(Stack Frame。每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

 

垃圾回收是否涉及栈内存?
  • 不会。栈帧内存 在每次方法调用结束后会自动弹出栈(自动回收),不需要回收(垃圾回收回收堆内存中无用对象,不会回收栈内存)
 
栈内存分配越大越好吗?
  • 不是。因为服务器中物理内存是固定大小的,单个栈内存大了,可创建的线程数就少了。虽然栈内可进行更多次方法调用,但由于线程数减少,所以并不会提高效率。
  • 可以通过 -Xss 参数来设置栈内存大小,JDK1.5+ 中默认是 1M,一般来说使用默认值即可
 
如何判断方法内的局部变量是否线程安全? 
  • 如果方法内的局部变量没有逃离方法的作用范围,那么它是线程安全的
  • 如果是局部变量引用了对象,并逃离了方法的作用范围,需要考虑线程安全

如下:

package JVM;

public class Demo01 {

    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append(4);
        sb.append(5);
        sb.append(6);
    }


    /**
     * 不会有线程安全问题。因为StringBuilder是线程内局部变量,属于线程私有,其他线程无法访问
     */
    public static void m1() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(1);
        stringBuilder.append(2);
        stringBuilder.append(3);
        System.out.println(stringBuilder.toString());
    }

    /**
     * 不是线程安全的。StringBuilder作为参数传入,StringBuilder可能被其他线程共享,不是线程安全
     */
    public static void m2(StringBuilder stringBuilder) {
        stringBuilder.append(1);
        stringBuilder.append(2);
        stringBuilder.append(3);
        System.out.println(stringBuilder.toString());
    }

    /**
     * 不是线程安全的。虽然StringBuilder是作为局部变量,但是返回结果为StringBuilder,可能被其他线程修改
     */
    public static StringBuilder m2() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(1);
        stringBuilder.append(2);
        stringBuilder.append(3);
        return stringBuilder;
    }
}

 

3.1.1、栈帧代码演示

代码如下:

/**
 * 演示栈帧
 */
public class Demo1_1 {
    public static void main(String[] args) throws InterruptedException {
        method1();
    }

    private static void method1() {
        method2(1, 2);
    }

    private static int method2(int a, int b) {
        int c =  a + b;
        return c;
    }
}

开启 debug 模式,执行 main 主方法,当调试执行到 method2 方法时,可以看到创建了三个栈帧。当方法 main、method1、method2 执行结束后,栈帧依次被销毁。

 

3.2、栈内存溢出(StackOverflowError)

  • 栈帧过多导致栈内存溢出。比如递归调用方法未正确结束递归
  • 栈帧过大导致栈内存溢出。

如下分别为栈帧过多和栈帧多大的示例图:

             

代码示例,如下是演示栈帧过多导致栈内存溢出的情况:

package cn.itcast.jvm.t1.stack;

/**
 * 演示栈内存溢出   报错信息:java.lang.StackOverflowError
 * 可以通过设置 JVM 参数来设置栈内存,如:-Xss256k
 */
public class Demo1_2 {
    private static int count;

    public static void main(String[] args) {
        try {
            method1();
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(count);
        }
    }

    private static void method1() {
        count++;
        method1();
    }
}

执行以上 main 方法,可以看到报错如下:

 

3.3、线程运行诊断

3.3.1、CPU占用过高

通过跑一段无限循环代码来使系统的 CPU 不断飙升,演示如何通过命令来诊断出导致 CPU 过高的线程。

代码示例:

package cn.itcast.jvm.t1.stack;

/**
 * 演示 cpu 占用过高
 */
public class Demo1_16 {

    public static void main(String[] args) {
        new Thread(null, () -> {
            System.out.println("1...");
            while(true) {

            }
        }, "thread1").start();


        new Thread(null, () -> {
            System.out.println("2...");
            try {
                Thread.sleep(1000000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thread2").start();

        new Thread(null, () -> {
            System.out.println("3...");
            try {
                Thread.sleep(1000000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thread3").start();
    }
}

代码编译后,传入 Linux 系统中,通过 java cn.itcast.jvm.t1.stack.Demo1_16 命令来运行该段程序。

然后通过 TOP 命令可以定位哪个进程对cpu的占用过高,如下:

 

通过 ps H -eo pid,tid,%cpu | grep 进程id  命令进一步定位是哪个线程引起的cpu占用过高,如下:

(注意,左边是进程id,右边是线程id)

最后通过 jstack 进程id  命令(jstack命令可以生成JVM当前时刻的线程快照。)打印出 JVM 当前线程,然后根据上面找到的线程 id 进一步定位到该线程内问题代码的源码行号。
注意,通过 jstack 进程打印出的线程显示的 id 是十六进制的,所以需要将上面占用 CPU 过高的线程 id 26384 转换为十六进制,即 6710。 

如上找到 CPU 占用过高的线程,并且可以定位到具体的代码类名和行数。

 

3.3.2、程序阻塞运行很久没有结果

如下,通过一段代码演示程序发生线程死锁。

代码如下:

package cn.itcast.jvm.t1.stack;

/**
 * 演示线程死锁
 */
class A{};
class B{};
public class Demo1_3 {
    static A a = new A();
    static B b = new B();


    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            synchronized (a) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (b) {
                    System.out.println("show a and b");
                }
            }
        }).start();
        Thread.sleep(1000);
        new Thread(()->{
            synchronized (b) {
                synchronized (a) {
                    System.out.println("show a and b 222");
                }
            }
        }).start();
    }

}

将该代码放置到 Linux 环境上执行,可以看到很久都没有输出结果。

 

当我们通过 jstack 命令来查看该进程的线程时,可以发现已经发生了死锁。

 

4、本地方法栈

在 java 虚拟机调用一些本地方法时需要给本地方法提供的内存空间。

  • 本地方法:由于java有限制,不可以直接与操作系统底层交互,所以需要一些用c/c++编写的本地方法与操作系统底层的API交互,java可以间接的通过本地方法来调用底层功能。本地方法是由其它语言编写的,编译成和处理器相关的机器代码。本地方法保存在动态链接库中,即.dll(windows系统)文件中,格式是各个平台专有的。

举例:Object的clone()、hashCode()、notify()、notifyAll()、wait()等,一个Native Method就是一个java调用非java代码的接口。

 

5、堆内存(Heap,线程共享)

5.1、堆内存的基本介绍(新生代、老年代、永久代)

Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象。通过 new 关键字,创建对象都会使用堆内存。

特点:

  • 它是线程共享的,堆中对象都需要考虑线程安全的问题。堆跟根程序计数器和虚拟机栈不同的是,后两者都是线程私有的,而堆是线程同享的
  • 有垃圾回收机制。当一个对象不再被使用时,该对象就会被垃圾回收机制回收,即该对象内存会被垃圾回收掉。

 

堆内存区域介绍:

 

在jvm的堆内存中有三个区域:

  1. 年轻代:用于存放新产生的对象。
  2. 老年代:用于存放被长期引用的对象。
  3. 持久带(或元空间):用于存放Class,method元信息(1.8之后改为元空间)。

详细介绍如下:

年轻代:年轻代中包含两个区:Eden 和survivor,并且用于存储新产生的对象,其中有两个survivor区。

老年代:年轻代在垃圾回收多次都没有被GC回收的时候就会被放到老年代,以及一些大的对象(比如缓存,这里的缓存是弱引用),这些大对象可以不进入年轻代就直接进入老年代

持久代:持久代用来存储class,method元信息,大小配置和项目规模,类和方法的数量有关。

元空间:JDK1.8之后,取消perm永久代,转而用元空间代替。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于元空间并不在虚拟机中,而是使用本地内存,并且可以动态扩容。

 

为什么分代?

因为不同对象的生命周期是不一样的。80%-98%的对象都是“朝生夕死”,生命周期很短,大部分新对象都在年轻代,可以很高效地进行回收,不用遍历所有对象。而老年代对象生命周期一般很长,每次可能只回收一小部分内存,回收效率很低。

年轻代和老年代的内存回收算法完全不同,因为年轻代存活的对象很少,标记清楚再压缩的效率很低,所以采用复制算法将存活对象移到survivor区,更高效。而老年代则相反,存活对象的变动很少,所以采用标记清楚压缩算法更合适。

5.2、堆内存溢出

堆内存溢出模拟代码:

package cn.itcast.jvm.t1.heap;

import java.util.ArrayList;
import java.util.List;

/**
 * 演示堆内存溢出 java.lang.OutOfMemoryError: Java heap space
 * 可以通过配置JVM参数:-Xmx8m 来设置最大堆内存
 */
public class Demo1_5 {

    public static void main(String[] args) {
        int i = 0;
        try {
            List<String> list = new ArrayList<>();
            String a = "hello";
            while (true) {
                list.add(a); // hello, hellohello, hellohellohellohello ...
                a = a + a;  // hellohellohellohello
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(i);
        }
    }
}

 

6、方法区(Method Area)

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。方法区存储类的结构的相关信息,如运行时常量池、成员变量、方法数据、成员方法和构造器的代码等。

方法区在虚拟机启动时创建,其逻辑上是堆的一个组成部分,但在实现时不同的JVM厂商可能会有不同的实现。方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

组成如下:以Oracle的HotSpot为例

  • jdk1.6:永久代(PermGen space),占用JVM内存空间

 

  •  jdk1.8:元空间(Metaspace),移出JVM内存(除StringTable),放入操作系统内存

 

6.1、方法区内存溢出

通过不断创建类来演示产生方法区内存溢出,如下:

package cn.itcast.jvm.t1.metaspace;

import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

/**
 * 元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
 * 设置元空间大小:-XX:MaxMetaspaceSize=8m

 * 永久代内存溢出  java.lang.OutOfMemoryError: PermGen space
 * 设置永久代内存大小:-XX:MaxPermSize=8m
 */
public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo1_8 test = new Demo1_8();
            for (int i = 0; i < 10000; i++, j++) {
                // ClassWriter 作用是生成类的二进制字节码
                ClassWriter cw = new ClassWriter(0);
                // 版本号, public, 类名, 包名, 父类, 接口
                cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                // 返回 byte[]
                byte[] code = cw.toByteArray();
                // 执行了类的加载
                test.defineClass("Class" + i, code, 0, code.length); // Class 对象
            }
        } finally {
            System.out.println(j);
        }
    }
}

当使用 jdk1.8 及之后的版本时,内存溢出报错提示:java.lang.OutOfMemoryError: Metaspace。当使用 jdk1.8 之前的版本时,内存溢出报错提示:java.lang.OutOfMemoryError: PermGen space

(默认的元空间内存大小为操作系统的内存大小,可能没那么容易产生内存溢出,可以通过设置 jvm 参数限制元空间内存大小来演示内存溢出现象)

 

posted @ 2022-08-11 21:05  wenxuehai  阅读(140)  评论(0编辑  收藏  举报
//右下角添加目录