JVM内存结构

1 程序计数器

1.1 定义

程序计数器Program Counter Register)是一块非常小的内存空间,我们可以把它看作是当前线程所执行的字节码行号指示器,它通过标识下一条需要完成的字节码指令来完成指令切换,可以说一个线程的运行就是在该计数器的不断变化推动下一步一步完成的。

1.2 特点

  • 它是一块很小的内存区域,几乎可以忽略不计,也是运行速度最快的内存区域
  • 每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致
  • 程序计数器是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法,如果当前线程正在执行的是Java方法,程序计数器记录的是JVM字节码指令地址,如果是执行native方法,则是未指定值(undefined)
  • 它是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域

2 虚拟机栈

2.1 定义

Java虚拟机栈(Java Virtual Machine Stacks):

  • 每个线程运行时所需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能由一个活动栈帧,对应着当前正在执行的那个方法

Java
public static void main(String[] args) {
    method1();
}

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

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

栈帧内部包括:

  • 局部变量表
  • 操作数栈
  • 方法返回地址
  • 动态链接
  • 附加信息

问题:

  1. 垃圾回收是否涉及栈内存?答:垃圾回收回收的是堆内存中的无用变量
  2. 栈内存分配越大越好吗?答:物理内存的大小是固定的,如果每个线程的栈内存越大,那同时运行的线程数量就会变少,所以不是越大越好
  3. 方法内的局部变量是否线程安全?答:如果方法内的局部变量没有逃离方法的作用范围,它是线程安全的;如果这个局部变量引用了对象,并逃离方法的作用方法,需要考虑线程安全

2.2 栈内存溢出

  • 栈帧过多导致栈内存溢出

Java
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();
}

java.lang.StackOverflowError

最终方法调用次数为22894

栈内存可以通过-Xss参数设置栈内存的大小

再次运行,只运行了3488次栈内存就溢出了

  • 栈帧过大导致栈内存溢出
  • 两个类循环引用,进行json转换可能会导致堆栈溢出

在《Java虚拟机规范》中,对这个内存区域规定了两类异常情况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
  • 如果Java虚拟栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

2.3 线程运行诊断

案例1cpu占用过多

定位:

  • top定位哪个进程对CPU的占用过高
  • Ps H -eo pid.tid,%cpu | grep 进程id(用ps命令进一步定位是哪个线程引起的cpu占用过高)
  • jstack 线程id
  • 可以根据线程id找到有问题的线程,进一步定位到问题代码的源码行号

案例2:程序运行很长时间没有结果

  • 用jstack排除死锁的问题

3 本地方法栈

一个Nativa Method就是一个Java调用非Java代码的接口。我们知道的Unsafe类就有很多本地方法。本地方法栈(Native Method Stacks)与虚拟机栈发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行java方法(字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

4

4.1 Java堆的概述

  • Java堆是被所有线程共享的一块内存区域,用于存储对象实例,几乎所有的对象实例都在这里分配内存。
  • Java堆也是垃圾收集器管理的内存区域,以G1收集器的出现为分界,往前的收集器基本是采用分代收集理论进行设计,所以新生代老年代永久代Eden空间From Survivor空间等概念都是分代设计下的产物,后面会介绍。垃圾分代的唯一目的就是优化GC性能
  • Java虚拟机规定,Java堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过-Xmx-Xms控制),如果堆中没有内存完成实例分配,并且堆无法再扩展时,就会抛出OutOffMemoryError异常。

4.2 堆内存溢出

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

4.3 堆内存诊断

  1. jps工具:查看当前系统中有哪些Java进程
  2. jmap工具:查看内存的占用情况(某一时刻)
  3. jconsole工具:图形界面的,多功能的监测工具,可以连续监测

Eg:

Java
public static void main(String[] args) throws InterruptedException {
    //jps
    System.out.println("1...");
    Thread.sleep(30000);
    //jmap -heap
进程id
    byte[] array = new byte[1024*1024*10];//10MB
    System.out.println("2...");
    Thread.sleep(30000);
    //jmap -heap
进程id
    array = null;
    System.gc();
    System.out.println("3...");
    //jmap -heap
进程id
    Thread.sleep(1000000L);

}

当前堆内存使用为7MB左右:

jmap -heap 进程id,此时已经申请了10MB的堆内存,当前堆内存占用为17MB左右:

jmap -heap 进程id,此时已经进行了垃圾回收,堆内存占用为1MB左右:

案例:垃圾回收后,内存占用仍然很高(使用jvisual)

先执行垃圾回收看看:

可见垃圾回收后,内存占用依然很高,此时点击堆dump

占用空间最大的是ArrayList:

点击进去查看发现里面有很多Student对象,对象里有一个byte数组占用空间1MB左右:

所以可以推断,这里有一个存储Students的容器ArrayList长时间使用,到这不能被垃圾回收掉

查看代码:

Java
public class demo10 {
    public static void main(String[] args) throws InterruptedException {
        List<Student> students = new ArrayList<>();
        for (int i = 0; i < 200; i++) {
            students.add(new Student());
        }
        Thread.sleep(100000000L);
    }
}
class Student{
    private byte[] big = new byte[1024*1024];
}

5 方法区

5.1 方法区概述

  • 方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息常量静态信息即时编译后的代码缓存等数据。
  • Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它有一个别名叫Non-Heap(非堆),目的应该是与Java堆分开。对于hotpost vm来说,在jdk1.7及以前,方法区的具体实现是永久代,而永久代是属于堆的一部分,那么方法区实际就是堆的一部分,但是要跟Java堆分开理解。
  • 《Java虚拟机规范》对方法区的约束是非常宽松的,除了和堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集器。
  • 方法区只是JVM规范中定义的一个概念,不同的厂商有不同的实现,而永久代(PereGen)是Hotspot虚拟机特有的概念,Java8的时候被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。两种实现存储内容不同,元空间存储类的元信息,而静态变量字符串常量池等并入堆空间中,相当于永久代的数据被分到了堆空间和元空间中。
  • Java7中我们通过-XX:PermSize-xx:MaxPermSize来设置永久代参数,Java8之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize-XX:MaxMetaspaceSize用来设置元空间参数。

所以总结方法区,Java8之后的变化:

  • 移除永久代(PermGen),替换为元空间(Metaspace)
  • 永久代中的class metadata转移到了native memory(本地内存,而不是虚拟机)
  • 永久代中的interned Strings和class static variables转移到了Java堆
  • 永久代参数(PermSize MaxPermSize)->元空间参数(MetaspaceSize MaxMetaspaceSize)

JVM1.6内存结构
 

JVM1.8内存结构
 

5.2 方法区内存溢出

  • 1.8以前会导致永久代内存溢出
  •         演示永久代内存溢出-XX:MaxPermSize=8m
  •         报错:java.lang.OutOfMemoryError: PermGen space
  • 1.8之后会导致元空间内存溢出

Java
/**
 * 演示元空间内存溢出
 * -XX:MaxMetaspaceSize=8m
 */
public class demo11 extends ClassLoader{//
可以用来加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0;
        try {
            demo11 test = new demo11();
            for(int i = 0;i<10000;i++,j++){
                //ClassWriter
作用是生成类的二进制字节码
                ClassWriter classWriter = new ClassWriter(0);
                //
版本号,public,类名,包名,父类,接口
                classWriter.visit(Opcodes.V1_1,Opcodes.ACC_PUBLIC,"Class"+i,null,"java/lang/Object",null);
                //
返回byte[]
                byte[] code = classWriter.toByteArray();
                //
执行了类的加载
                test.defineClass("Class"+i,code,0,code.length);//Class对象
            }
        }finally {
            System.out.println(j);
        }
    }
}

产生场景:

  • Spring
  • Mybatis

5.3 运行时常量池

  • 一个类的二进制字节码文件包括类基本信息常量池类方法定义虚拟机指令
  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名方法名参数类型字面量等信息
  • 运行时常量池(就是上面图中的常量池),常量池是*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

5.4 StringTable

问:

Java
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();

//

System.out.println(s3 == s4);//false
System.out.println(s3 == s5);//true
System.out.println(s3 == s6);//true

String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();

//问,如果调换了最后两行代码的位置呢,如果是jdk1.6呢 //true false
System.out.println(x1==x2);//false

要想知道答案,接着往下看。

Java
//StringTable[ "a","b","bc"  ]
public class Demo01 {
    //
常量池原本在字节码文件中
    //运行时,常量池中的信息都会被加载到运行时常量池中,这时a b ab 都是常量池中的符号,还没有变为java字符串中的字符
    //运行到String s1 = "a"; ldc #2 会把a符号变为“a"字符串对象,查找串池中有没有,没有就将将"a"放入串池
    //ldc #3 会把b符号变为“b"字符串对象
    //ldc #4 会把ab符号变为“ab"字符串对象
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1+s2;//new StringBuilder().append("b").append("b").toString() new String("ab")
        String s5  = "a" + "b";//javac
在编译期间的优化,结果已经在编译期间确定为ab,因为进行拼接的常量字符串,不会再改变

        System.out.println(s3==s4);//false
        System.out.println(s3 == s5);//true
    }
}

注:字符串字面量也是【延迟】成为对象的,即运行到那一行才加入串池

5.4.1 StringTable特性

  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder(1.8)
  • 字符串常量拼接的原理是编译期优化
  • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池
  • 1.8 将这个字符串对象尝试放入串池,如果有则不会放入,如果没有则放入串池,会把串池中的对象返回
  • 1.6将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把次对象复制一份,放入串池,会把串池中的对象返回

Java
public static void main(String[] args) {
    String s = new String("a") + new String("b");//new String("ab")

    //
堆里面有 new String("a") new String("b") new String("ab")
    //串池中[ "a","b" ]

    String s2 = s.intern();//将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,并把串池中的对象返回
    System.out.println(s2=="ab");//true
    System.out.println(s=="ab");//true s
引用的对象和串池中的是同一个
}

如果先在串池中放一个"ab"

Java
public static void main(String[] args) {
    String x = "ab";
    String s = new String("a") + new String("b");//new String("ab")

    //
堆里面有 new String("a") new String("b") new String("ab")
    //串池中[ "a","b","ab" ]

    String s2 = s.intern();//将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,并把串池中的对象返回
    //此时串池中有"ab"了
    System.out.println(s2=="ab");//true s2是串池中返回的对象
    System.out.println(s=="ab");//false
}

5.4.2 StringTable位置

先看案例:

Java
/**
 * 演示在StringTable位置
 * 在jdk1.8下设置 -Xmx10m -XX:UserGCOverheadLimit
 * 在jdk1.6下设置 -XX:MaxPermSize=10m
 */
public class Demo03 {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        int i = 0;
        try {
            for (int j = 0; j < 260000; j++) {
                list.add(String.valueOf(j).intern());
                i++;
            }
        }catch (Throwable e){
            e.printStackTrace();
        }finally {
            System.out.println(i);
        }
    }
}

这是一个一般情况下正常运行的程序。

  • 当我们在jdk1.6的环境下运行时,将永久代大小设置为10m-XX:MaxPermSize=10m会发生内存溢出java.lang.OutOfMemoryError:PermGen space
  • 当我们在jdk1.7的环境下运行时,设置最大堆内存-Xmx10报错java.lang.OutOfMemoryError:GC overhead limit exceeded(垃圾回收器花了98%的精力来回收堆内存,但是只回收了2%不到)
  • 再添加-XX:UserGCOverheadLimit关掉垃圾回收器的限制,报错java.lang.OutOfMemoryError:java heap space

从这个案例我们可以看出jdk1.6的串池用的是永久代jdk1.8的串池用的是堆空间

5.4.3 StringTable垃圾回收

案例:

Java
/**
 * 演示StringTable垃圾回收
 * -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
 */
public class Demo04 {
    public static void main(String[] args) {
        int i = 0;
        try {
        }catch (Throwable e){
            e.printStackTrace();
        }finally {
            System.out.println(i);
        }
    }
}

此时未进行垃圾回收:

try中加入:

Java
for (int j = 0; j < 100; j++) {
    String.valueOf(j).intern();
    i++;
}

加了100个新的字符串对象放入StringTable,实际上增长的不止100个,我也不知道为什么

现在修改为加入1000个字符串,由于堆内存不足,触发了垃圾回收

此时StringTable中的字符串对象少于10000个:

5.4.4 StringTable性能调优

  • StringTable的底层是一个哈希表,哈希表的性能和哈希表的大小有关,哈希表越小越容易产生哈希碰撞,查找越慢,也就是说哈希表的性能和哈希表的桶个数息息相关,通过修改哈希表的桶个数可以调节StringTable的性能。

Java
/**
 * -XX:StringTableSize=哈希表桶个数 -XX:PrintStringTableStatistics
 */
public class Demo5 {
    public static void main(String[] args) throws IOException {
        try(BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("
一个字符串很多的文件"),"utf-8"))){
            String line = null;
            long start = System.nanoTime();
            while (true){
                line = reader.readLine();
                if (line==null){
                    break;
                }
                line.intern();
            }
            System.out.println("cost:"+(System.nanoTime()-start)/1000000);
        }
    }
}

  • 考虑是否入池,以减少内存占用

Java
/**
 * -XX:StringTableSize=
哈希表桶个数 -XX:PrintStringTableStatistics
 */
public class Demo5 {
    public static void main(String[] args) throws IOException {
        List<String> address = new ArrayList<>();
        System.in.read();
        for (int i = 0; i < 10; i++) {//单词都会被重复读取10次
            try(BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"),"utf-8"))){
                String line = null;
                long start = System.nanoTime();
                while (true){
                    line = reader.readLine();
                    if (line==null){
                        break;
                    }
                    //address.add(line);内存占用高
                    //加入生命周期更长的list中防止被垃圾回收
                    address.add(line.intern());//内存占用相对较低
                }
                System.out.println("cost:"+(System.nanoTime()-start)/1000000);
            }
        }
        System.in.read();
    }
}

  1. address.add(line):每次都向堆中添加一个字符串对象,add()方法不会检查常量池中是否存在相同的字符串,而是在堆中创建一个新的对象,并将其引用添加到列表中。
  2. address.add(line.intern()):将line.intern()的结果添加到列表address中,intern()方法会尝试将line放入字符串常量池中,并返回常量池中的引用,因此,如果line在常量池中已经存在,则address中添加的是常量池中的引用,而不是堆中的对象,这样多个相同的字符串可以共享一个常量池中的实例,而不是每次都创建新的对象,可以节省内存。

6 本地内存和直接内存

6.1 概述

6.1.1 本地内存

  • 概念:本地内存通常指的是Java应用程序运行时,Java虚拟机(JVM)之外的内存空间。
  • 本地内存不受Java虚拟机的管理,也不由Java的垃圾回收器进行回收,它是操作系统层面上分配和管理的内存
  • 在HotSpot中,JDK1.8用元空间实现方法区,将元数据从虚拟机运行时数据区转移到本地内存中,也就是说,这块区域的使用时受物理内存限制的,当申请的内存超过了本机物理内存,才会抛出OutOfMemoryError异常

6.1.2 直接内存

Java应用程序是用户态的,如果要访问磁盘的数据,要先切换到内核态,然后访问磁盘,将磁盘中要访问的数据复制到系统内存中,再从系统内存中复制到Java堆内存中,才能完成这一访问的过程,这样的复制操作会消耗大量的CPU时间和内存带宽,所以我们引入了直接内存

不使用直接内存时
 

  • 直接内存Java中的一种特殊内存区域,主要通过ByteBuffer类来进行访问和管理,操作系统和Java代码都可以访问这块内存区域,所以无需将要访问的数据从操作系统复制到Java堆内存,从而提高了效率。
  • 实现:JDK1.4中新加入的NIO(new input/output)类,引入了一种基于通道(channel)和缓冲区的I/O方式,直接内存的实现,在底层使用native函数直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用来进行操作,这样避免了在Java堆和Native堆中来回复制数据,显著提高新能
  • 直接内存的分配是通过ByteBuffer.allocateDirect()方法来完成的,而不是通过传统的new关键字来分配堆内存,这种方法的底层利用了操作系统的内存管理机制,也就是调用了native函数来分配堆外内存。
  • 分配和管理:直接内存的分配和释放在表面上是由Java虚拟机控制的,但实际上这些操作是通过调用操作系统的本地内存管理接口实现的。

使用直接内存时
 

  • 9
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值