JVM-内存结构

77 阅读16分钟

JVM-内存结构

1.程序计数器

程序计数器可以记住吓一条jvm指令的执行地址。

1.1程序计数器实现

物理上是通过寄存器实现的。将cpu中的寄存器当成程序计数器。

1.2特点

线程私有

cpu在多线程的时候,会给线程分配时间片(会让每个线程都可以执行)

线程切换的时候,如果要知道下一个线程进行到哪里,就要程序计数器。

每个线程都有自己的程序计数器

不会存在内存溢出

程序计数器部分没有内存溢出。

2.虚拟机栈

虚拟机栈:线程运行时需要的内存空间。

2.1栈的数据结构

先进后出。

2.2栈的组成

java中每一线程运行的时候,都需要给每一个线程划分内存空间。一个线程对应一个栈。

栈帧

栈帧:每个方法运行时的内存

每一个栈都有多个栈帧,栈帧里会有参数,局部变量和返回地址。

每个线程只能有一个活动栈帧,对应着当前正在执行的方法。

image-20231012170041295

一个栈帧就对应一个方法的调用,代码是由一个个方法的来组成,每个方法需要的内存就是栈帧。

当调用结束之后就会将栈帧出栈。

2.3问题辨析

1.垃圾回收是否涉及栈内存?

栈帧内存每一次方法调用结束之后会弹出栈自动回收掉,所以不需要垃圾回收,垃圾回收是回收堆内存的。

2.栈内存分配越大越好么?

栈内存划的越大,线程数会越少。线程数和栈内存划分是成反比关系的,因为物理内存是一定的。一般采用系统默认内存就好。

3.方法内的局部变量是否线程安全?

看一个变量是不是线程安全,要看多个线程对变量共享,还是这个变量对每个线程是私有的。

判断一个变量是不是线程安全,要看是不是方法内的局部变量,还是否逃离了方法的作用范围,如果逃离了,会被其他线程访问到,就不是安全的了。

  • 如果方法内部局部变量没有逃离方法的作用范围,他是线程安全的。
  • 如果是局部变量引用了对象,并且逃离方法的作用范围,需要考虑线程安全。

2.4栈内存溢出

1.栈内存溢出情况

1.1栈帧过多

例子:

栈的大小是固定的

如果在方法递归调用的时候,没有设置好调用条件,自己调用自己了,就会出现栈内存溢出。

错误名称java.lang.StackOverFlowError

1.2栈帧过大

-Xss256k设置栈内存大小

分配了256K

栈大小减小了,有1024变成256.

2.5线程运行诊断

案例1:cpu占用过多

定位

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

这里的线程id是16进制的,要转换

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

3.本地方法栈

在java虚拟机调用本地方法时,需要给这些本地方法提供的一个内存空间。指的是不是由java代码编写的方法。java代码有时不能和操作系统底层api打交道,但是c等语言可以,java可以通过一些本地的方法来简介地调用系统底层。

例如:

hashcode等也是

4.堆

4.1定义

Heap堆

  • 通过new关键字,创建对象都会使用堆内存

特点

  • 它是线程共享的,堆中对象都需要考虑线程安全的问题
  • 有垃圾回收机制

4.2堆内存溢出问题

对象被当成垃圾的条件是没人使用了,如果创建了很多对象又被频繁的使用,那么堆内存就有可能被耗尽。

例如:

报错:java.lang.OutOfMemoryError:Java heap space

4.3-Xmx

-Xmx可以在编辑配置里,配置堆空间大小

例如-Xmx8M,堆空间就变成8M了

4.4堆内存诊断

1.jps工具(命令行)

  • 查看当前系统又哪些java进程
  • jps

2.jmap工具(命令行)

  • 查询某一时刻堆内存的占用情况
  • jmap -heap 进程id

Eden space:

对象刚创建时候的内存情况

3.jconsole工具

  • 图形界面,多功能监测工具,可以连续监测
  • 在命令行输入,选择目标进程,就可以连上了

案例-★

演示查看对象个数,堆转储,dump Demo1_13

  • 垃圾回收之后,内存占用仍然很高
  • 工具:终端输入:jvisualvm 可视化工具展示虚拟机内容
  • jvisualvm功能:堆dump

堆dump:将对应时刻的堆内存信息截取下来了,每一个对象以及对应的个数也被截取下来了。

5.方法区

5.1定义

方法区是所有java虚拟机线程共享的一块区域,存储了跟类的结构相关信息,类的field成员变量,方法数据,成员方法和构造器方法的代码部分,特殊方法(类构造器)。就是存储跟类相关的一些信息。

run-time constant pool

方法区会在虚拟机启动的时候被创建,逻辑上是堆的一个组成部分

方法区是一种规范,永久代和元空间只是一种实现。

方法区申请内存的时候也会造成方法区溢出的错误。

5.2组成

5.3方法区内存溢出

1.8以后,方法区的实现是由元空间实现的。

eg:控制元空间,演示内存溢出

-Xx : MaxPermSize=8m

报错异常依然是:OutofMemoryError:MetaSpace

1.8以前是OutofMemoryError:PermGen space永久代

5.4运行时常量池

场景

会用到字节码的一些技术例如cglib等

  • spring框架
  • mybatis框架

会产生大量的运行时的对象。若遇到溢出,看是否是框架使用不得当。

常量池

定义
  • 就是一张表,虚拟机指令根据这些常量表找到要执行的类名,方法名,参数类型,字面量等信息。
  • 运行时常量池,常量池是*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真是地址。

jdk提供javap工具,反编译字节码,-v返回编译后的详细信息

javap -c HelloWorld.class (类名)

去常量池进行查找,知道了每个指令要去执行哪个类的哪个方法,参数是什么。

常量池:给指令提供常量符号并将其找到。

5.5StringTable

面试题

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();
System.out.println(x1 == x2);//false 
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
​
逐步分析
​
// StringTable [ "a", "b" ,"ab" ]  hashtable 结构,不能扩容
public class Demo1_22 {
    // 常量池中的信息,都会被加载到运行时常量池中, 这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
    // ldc #2 会把 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("a").append("b").toString()  new String("ab")
        String s5 = "a" + "b";  // javac 在编译期间的优化,结果已经在编译期确定为ab
​
        System.out.println(s3 == s5);
​
​
​
    }
}
​
​

先进行编译:

在javap反编译的时候注意终端路径。

常量池最初存在于字节码文件里,当运行的时候,就会加载到运行时常量池,但是加载到运行时常量池.

ldc #2 会把 a 符号变为 "a" 字符串对象,变成字符串对象之后,还会准备一块空间:StringTable(本质是hash表,长度固定,无法扩容),会把a变成key去StringTable里去找,如果没有该对象,则会把生成的a对象放StringTable里。

懒惰创建

  • 字符串对象只有当用到的时候才会创建,如果没用到不回提前创建。
public class Demo1_22 {
    // 常量池中的信息,都会被加载到运行时常量池中, 这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
    // ldc #2 会把 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("a").append("b").toString()  new String("ab")
​
    }
}
​
  String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString()  new String("ab")

s4是新创建的一个对象.

  String s1 = "a"; // 懒惰的
        String s2 = "b";
        String s3 = "ab";

创建的对象在放入StringTable里

// StringTable [ "a", "b" ,"ab" ]  hashtable 结构,不能扩容

 String s4 = s1 + s2; // new 

创建的对象在堆Heap里面.

  public static void main(String[] args) {
        String s1 = "a"; // 懒惰的
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString()  new String("ab")
        String s5 = "a" + "b";  // javac 在编译期间的优化,结果已经在编译期确定为ab
​
        System.out.println(s3 == s5);
        
    }
 String s5 = "a" + "b";  

的字节码

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

javac在编译期间,认为"a" + "b"是常量,所以拼接结果是确定的,那么在编译期间就已经知道结果,不能是别的值,而s1,s2是对象是变量,运行时候的值有可能发生修改,结果不能确定,必须用stringbuilder方法动态创建。

StringTable特性

  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是 StringBuilder (1.8)
  • 字符串常量拼接的原理是编译期优化

可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池

  • jdk:1.8.将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
  • jdk:1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象(堆中的对象)复制一份,放入串池, 会把串池中的对象返回。因此堆内的对象和串池中的对象是不同的。
public class Demo1_23 {

    //  StringTable["ab", "a", "b"]
    public static void main(String[] args) {

        //String x = "ab";
        String s = new String("a") + new String("b");

        // 堆  new String("a")   new String("b") new String("ab")
        String s2 = s.intern(); // 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回

        System.out.println( s2 == "ab");//true
        System.out.println( s == "ab" );//true
        
    }

}
 String s2 = s.intern();

将这个字符串对象s尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回。也就是s也在串池里面了。

String x = "ab";的位置不同

public class Demo1_23 {

    //  StringTable["ab", "a", "b"]
    public static void main(String[] args) {

        String x = "ab";
        String s = new String("a") + new String("b");
//"a",在StringTable中创建
        // 堆  new String("a")   new String("b") new String("ab")
        String s2 = s.intern(); // 将这个字符串对象s尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回说s2

        System.out.println( s2 == x);//true
        System.out.println( s == x );//false
        
    }

}

StringTable位置

永久代的内存回收效率低,-Gc需要等待老年代不足的时候才会触发,触发的时间晚,导致StringTable回收效率低,而StringTable回收效率低则会占用大量内存,导致永久代内存不足,所以从1.7开始,就从常量池里转到了堆里。

StringTable垃圾回收机制

package cn.itcast.jvm.t1.stringtable;

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

/**
 * 演示 StringTable 垃圾回收
 //第一个参数是虚拟机堆内存最大值,第二个是打印字符串表的统计信息,可以看到串池中字符串的个数和大小,最后一个是垃圾回收的信息,次数和花费的时间等。将参数加入到程序运行设置
 * -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
 */
public class Demo1_7 {
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        try {
            for (int j = 0; j < 100000; j++) { // j=100, j=10000
                String.valueOf(j).intern();
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }

    }
}

多次运行,触发垃圾回收:

无用的字符串常量就会被垃圾回收掉。

StringTable性能调优★

桶个数配置

哈希表的性能与大小密切相关,如果哈希表的桶比较多,相对元素比较分散,哈希碰撞频率减少,查找频率变快。反之桶如果比较少,链表较长,查询就会变慢。

/**
 * 演示串池大小对性能的影响
 * -Xms500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=1009
 */
public class Demo1_24 {

    public static void main(String[] args) throws IOException {
        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;
                }
                line.intern();
            }
            System.out.println("cost:" + (System.nanoTime() - start) / 1000000);//打印花费的时间
        }
    }
}

查找变慢:

增加桶个数:

花费时间减少

去除重复的地址

考虑将字符串是否入池

public class Demo1_25 {

    public static void main(String[] args) throws IOException {

        List<String> address = new ArrayList<>();//创建line防止被回收
        System.in.read();
        for (int i = 0; i < 10; i++) {
            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;
                    }
                    //在拿到字符串对象时候做入池动作,把串池中的对象加入到list里
                    address.add(line.intern());
                }
                System.out.println("cost:" +(System.nanoTime()-start)/1000000);
            }
        }
        System.in.read();
    }
}

不入池:

 address.add(line);

6.直接内存

定义

直接内存属于系统内存,操作系统的内存。

  • 常见于 NIO 操作时,用于数据缓冲区,nio里有bytebuffer
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理

直接内存的使用:

/**
 * 演示 ByteBuffer 作用
 */
public class Demo1_9 {
    static final String FROM = "E:\编程资料\第三方教学视频\youtube\Getting Started with Spring Boot-sbPSjI4tt10.mp4";
    static final String TO = "E:\a.mp4";
    static final int _1Mb = 1024 * 1024;

    public static void main(String[] args) {
        io(); // io 用时:1535.586957 1766.963399 1359.240226
        directBuffer(); // directBuffer 用时:479.295165 702.291454 562.56592
    }

    private static void directBuffer() {
        long start = System.nanoTime();
        try (FileChannel from = new FileInputStream(FROM).getChannel();
             FileChannel to = new FileOutputStream(TO).getChannel();
        ) {
            ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
            while (true) {
                int len = from.read(bb);
                if (len == -1) {
                    break;
                }
                bb.flip();
                to.write(bb);
                bb.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
    }

    private static void io() {
        long start = System.nanoTime();
        try (FileInputStream from = new FileInputStream(FROM);
             FileOutputStream to = new FileOutputStream(TO);
        ) {
            byte[] buf = new byte[_1Mb];
            while (true) {
                int len = from.read(buf);
                if (len == -1) {
                    break;
                }
                to.write(buf, 0, len);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("io 用时:" + (end - start) / 1000_000.0);
    }
}

文件读写过程:

java本身没有磁盘读写的功能,他需要调用本地方法nativeMethod,操作系统提供的。

内核态:在操作系统的地方开辟一个系统缓存区,利用系统缓存区分次读取,而java无法直接读取系统内存,则会在java堆内存里划分一块缓存区,把系统缓存区的数据间接地调用到java缓冲区。

造成了不必要的数据备份复制。

DirectBuffer:

开辟出了一个java和系统都可以使用的内存。少了一次缓冲区赋值操作。

演示内存溢出的案例:

public class Demo1_10 {
    static int _100Mb = 1024 * 1024 * 100;

    public static void main(String[] args) {
        //生命周期更长的list
        List<ByteBuffer> list = new ArrayList<>();
        int i = 0;
        try {
            while (true) {
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
                list.add(byteBuffer);
                i++;
            }
        } finally {
            System.out.println(i);
        }
        // 方法区是jvm规范, jdk6 中对方法区的实现称为永久代
        //                  jdk8 对方法区的实现称为元空间
    }
}

内存回收实现

/**
 * 禁用显式回收对直接内存的影响
 */
public class Demo1_26 {
    static int _1Gb = 1024 * 1024 * 1024;

    /*
     *JVM调优时候常加的参数
     * -XX:+DisableExplicitGC 禁用显式的垃圾回收
     *让System.gc()无效,它是显示的回收,会造成程序暂停时间比较长
     */
    public static void main(String[] args) throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
        System.out.println("分配完毕...");
        System.in.read();
        System.out.println("开始释放...");
        byteBuffer = null;
        System.gc(); // 显式的垃圾回收,Full GC
        System.in.read();
    }
}
释放直接内存的原理
  • 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
  • ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存
/**
 * 直接内存分配的底层原理:Unsafe
 */
public class Demo1_27 {
    static int _1Gb = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException {
        Unsafe unsafe = getUnsafe();
        // 分配内存
        long base = unsafe.allocateMemory(_1Gb);
        unsafe.setMemory(base, _1Gb, (byte) 0);
        System.in.read();

        // 释放内存
        unsafe.freeMemory(base);
        System.in.read();
    }

    public static Unsafe getUnsafe() {
        try {
            //通过反射的方法拿到unSafe对象
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe = (Unsafe) f.get(null);
            return unsafe;
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

必须要主动调用unsafe.freeMemory(base);才能对其进行释放

分析DirectByteBuffer源码:

// Primary constructor
    //
    DirectByteBuffer(int cap) {                   // package-private

        super(-1, 0, cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        Bits.reserveMemory(size, cap);

        long base = 0;
        try {
            //调用unsafe对象
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        if (pa && (base % ps != 0)) {
            // Round up to page boundary
            address = base + ps - (base & (ps - 1));
        } else {
            address = base;
        }
        //cleaner特殊对象,newDeallocator
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;

    }

Cleaner是在java类库里特殊的类型:虚引用类型。特点:当它所关联的对象被回收时,就会触发虚引用对象中的clean方法。

//虚引用对象
public class Cleaner
    extends PhantomReference<Object>

clean():不在主线程中执行,在后台的referenceHandler线程,咋混们监测虚引用对象,当虚引用对象关联的实际对象,directByteBuffer被回收以后,就会调用到虚引用对象的clean方法,执行任务对象的run方法,再去调用freeMemory


public void clean() {
        if (!remove(this))
            return;
        try {
            //触发任务对象的run方法
            thunk.run();
        } catch (final Throwable x) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                
                    public Void run() {
                        if (System.err != null)
                            new Error("Cleaner terminated abnormally", x)
                                .printStackTrace();
                        System.exit(1);
                        return null;
                    }});
        }
    }

Deallocator:

  private static class Deallocator
        implements Runnable
    {

        private static Unsafe unsafe = Unsafe.getUnsafe();

        private long address;
        private long size;
        private int capacity;

        private Deallocator(long address, long size, int capacity) {
            assert (address != 0);
            this.address = address;
            this.size = size;
            this.capacity = capacity;
        }

        public void run() {
            if (address == 0) {
                // Paranoia
                return;
            }
            //主动调用freeMemory释放内存
            unsafe.freeMemory(address);
            address = 0;
            Bits.unreserveMemory(size, capacity);
        }

    }

JVM调优时候常加的参数 * 问题:-XX:+DisableExplicitGC 禁用显式的垃圾回收让System.gc()无效,它是显示的回收,会造成程序暂停时间比较长。但是禁用之后,直接内存的使用是有影响的。不能通过显示的代码回收,只有等到真正的垃圾回收发生时,才会被清理,对应的直接内存再被释放掉,直接内存占用较大。 * 解决:直接手动unsafe.freeMemory手动管理直接内存