JVM探究

  • 请你谈谈对JVM的理解? java8虚拟机和之前的变化更新
  • 什么是OOM(内存溢出)? 什么是栈溢出(stackOverFlowError)? 怎么分析?
  • JVM常用的调优参数有哪些?
  • 内存快照如何抓取? 怎么分析Domp文件 ? 知道吗?
  • 谈谈JVM中,类加载器你的认识?

1.jvm的位置

在这里插入图片描述

jvm运行在操作系统之上,java程序运行在jvm上,jvm用C写的

jre包含jvm

2.jvm的体系结构

在这里插入图片描述

在这里插入图片描述

方法区属于一个特殊的堆,属于堆里的一块。

jvm调优大部分是在调堆。

3.类加载器

作用:加载Class文件

在这里插入图片描述

public class Car {

    public static void main(String[] args) {
        //类是模板,对象是具体

        Car car1=new Car();
        Car car2 = new Car();
        Car car3 = new Car();

        System.out.println(car1.hashCode()); //356573597
        System.out.println(car2.hashCode()); //1735600054
        System.out.println(car3.hashCode()); //21685669

        Class<? extends Car> aClass1 = car1.getClass(); 
        Class<? extends Car> aClass2 = car2.getClass(); 
        Class<? extends Car> aClass3 = car3.getClass(); 

        System.out.println(aClass1);  //class Car
        System.out.println(aClass2);  //class Car
        System.out.println(aClass3);  //class Car

        ClassLoader classLoader = aClass1.getClassLoader();

        System.out.println(classLoader);  //AppClassLoader
        System.out.println(classLoader.getParent()); //ExtClassLoader
        System.out.println(classLoader.getParent().getParent()); //null
        //null 两种可能 1.不存在 2.Java程序获取不到
    }
}

类是模板,对象是具体的

类加载器:
 1.虚拟机自带的加载器
 2.启动类(根)加载器
 3.扩展类加载器 ExtClassLoader
 4.应用程序加载器 AppClassLoader

4. 双亲委派机制

双亲委派机制:安全

1.APP---->EXC---->B0OT(最终执行)

B0OT

EXC

APP

1.类加载器收到类加载的请求

2.将这个请求向上委托给父类加载器去完成,一 直向上委托,直到启动类加载器

3.启动加载器检查是否能够加载当前这个类,能加载就结束, 使用当前的加载器,否则, 抛出异常,通知子加载器进行加载

4.重复步骤3

Class Not Found异常就是这么来的

Java早期的名字:C++  Java = C+±-:去掉繁琐的东西,指针,内存管理~

通俗易懂的双亲委派机制

当你超过别人一点点,别人会嫉妒你;当你超过别人一大截,别人就会羡慕你

你得先知道

在介绍双亲委派机制的时候,不得不提ClassLoader(类加载器)。说ClassLoader之前,我们得先了解下Java的基本知识。
Java是运行在Java的虚拟机(JVM)中的,但是它是如何运行在JVM中了呢?我们在IDE( 集成开发环境 )中编写的Java源代码被编译器编译成**.class**的字节码文件。然后由我们得ClassLoader负责将这些class文件给加载到JVM中去执行。
JVM中提供了三层的ClassLoader:

  • Bootstrap classLoader:主要负责加载核心的类库(java.lang.*等),构造ExtClassLoader和APPClassLoader。

  • ExtClassLoader:主要负责加载jre/lib/ext目录下的一些扩展的jar。

  • AppClassLoader:主要负责加载应用程序的主函数类

那如果有一个我们写的Hello.java编译成的Hello.class文件,它是如何被加载到JVM中的呢?别着急,请继续往下看。

双亲委派机制

我打开了我的AndroidStudio( 是谷歌推出的一个Android集成开发工具,基于IntelliJ IDEA ),搜索了下“ClassLoader”,然后打开“java.lang”包下的ClassLoader类。然后将代码翻到loadClass方法:

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }
    //              -----??-----
    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // 首先,检查是否已经被类加载器加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    // 存在父加载器,递归的交由父加载器
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        // 直到最上面的Bootstrap类加载器
                        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.
                    c = findClass(name);
                }
            }
            return c;
    }

其实这段代码已经很好的解释了双亲委派机制,为了大家更容易理解,我做了一张图来描述一下上面这段代码的流程:

img

​ 从上图中我们就更容易理解了,当一个Hello.class这样的文件要被加载时。不考虑我们自定义类加载器,首先会在AppClassLoader中检查是否加载过,如果有那就无需再加载了。如果没有,那么会拿到父加载器,然后调用父加载器的loadClass方法。父类中同理也会先检查自己是否已经加载过,如果没有再往上。注意这个类似递归的过程,直到到达Bootstrap classLoader之前,都是在检查是否加载过,并不会选择自己去加载。直到BootstrapClassLoader,已经没有父加载器了,这时候开始考虑自己是否能加载了,如果自己无法加载,会下沉到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException。那么有人就有下面这种疑问了?

为什么要设计这种机制

​ 这种设计有个好处是,如果有人想替换系统级别的类:String.java。篡改它的实现,在这种机制下这些系统的类已经被Bootstrap classLoader加载过了(为什么?因为当一个类需要加载的时候,最先去尝试加载的就是BootstrapClassLoader),所以其他类加载器并没有机会再去加载,从一定程度上防止了危险代码的植入。

总结了一张脑图如下:
img

5. 沙箱安全机制

沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。

​ Java安全模型的核心就是Java沙箱(sandbox) ,  什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。

​ 沙箱主要限制系统资源访问,那系统资源包括什么? CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。

​ 所有的Java程序运行都可以指定沙箱,可以定制安全策略。  在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱Sandbox)机制。如下图所示JDK1.0安全模型
在这里插入图片描述

但如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略,允许用户指定代码对本地资源的访问权限。如下图所示JDK1.1安全模型

在这里插入图片描述

在Java1.2版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示

在这里插入图片描述

当前最新的安全机制实现,则引入了域(Domain)的概念。虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域(Protected Domain),对应不一样的权限(Permission)。存在于不同域中的类文件就具有了当前域的全部权限,如下图所示最新的安全模型(jdk 1.6)
在这里插入图片描述

组成沙箱的基本组件

字节码校验器(bytecode verifier) :确保Java类文件遵循Java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。

类裝载器(class loader) :其中类装载器在3个方面对Java沙箱起作用

  • 它防止恶意代码去干涉善意的代码;  //双亲委派机制

  • 它守护了被信任的类库边界;

  • 它将代码归入保护域,确定了代码可以进行哪些操作。

    (new Robot();机器人类,可以操作电脑)

虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成, 每一个被装载的类将有一个名字,这个命名空间是由Java虚拟机为每一个类装载器维护的,它们互相之间甚至不可见。

​ 类装载器采用的机制是双亲委派模式。

1.从最内层JVM自带类加载器开始加载,外层恶意同名类得不到加载从而无法使用;

2.由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就自然无法生效。

存取控制器(access controller) :存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定,可以由用户指定。

安全管理器(security manager) : 是核心API和操作系统之间的主要接口。实现权限控制,比存取控制器优先级高。

安全软件包(security package) : java.security下的类和扩展包下的类,允许用户为自己的应用增加新的安全特性,包括:

  • 安全提供者

  • 消息摘要

  • 数字签名  keytools https

  • 加密

  • 鉴别

6.Native*

编写一个多线程类启动

public static void main(String[] args){
    new Thread(()->{
        
    },"your thread name").start();
}

点进去看start方法的源码

 public synchronized void start() {
        
        if (threadStatus != 0)
            throw new IllegalThreadStateException();

        
        group.add(this);

        boolean started = false;
        try {
            start0();//调用了一个start0方法
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                
            }
        }
    }
//这个Thread是一个类,这个方法定义在这里是不是很诡异!看这个关键字natie
    private native void start0();

Native 关键字 调用 本地方法栈----> JNI (java Native Interface本地方法接口) 调用外部语言程序

native :凡是带了native关键字的,说明java的作用范围达不到了,回去调用底层c语言的库!

会进入本地方法栈

调用本地方法本地接口 JNI (Java Native Interface)

JNI作用:开拓Java的使用,融合不同的编程语言为Java所用!最初: C、C++

Java诞生的时候C、C++横行,想要立足,必须要有调用C、C++的程序

它在内存区域中专门开辟了一块标记区域: Native Method Stack,登记native方法

在最终执行的时候,加载本地方法库中的方法通过JNI

例如:Java程序驱动打印机,管理系统,掌握即可,在企业级应用比较少

private native void start0();

//调用其他接口:Socket. . WebService~. .http~

Native Method Stack

它的具体做法是Native Method Stack中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies。[本地库]

Native Interface本地接口

本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序, Java在诞生的时候是C/C++横行的时候,想要立足,必须有调用C、C++的程序,于是就在内存中专门开辟了块区域处理标记为native的代码,它的具体做法是在Native Method Stack 中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies。  目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍!

7.PC寄存器

程序计数器: Program Counter Register

​ 每个线程都有一个程序计数器,是线程私有的,就是一个指针, 指向方法区中的方法字节码(用来存储指向像一条指令的地址, 也即将要执行的指令代码),在执行引擎读取下一条指令, 是一个非常小的内存空间,几乎可以忽略不计

8.方法区 Method Area

方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间;

静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关

static final Class 常量池

public class Test{
    private int a;
    private String name="qinjiang";
}

new Test();

在这里插入图片描述

9.栈

程序=数据结构+算法 :持续学习

程序=框架+业务逻辑 :很容易被淘汰 吃饭

在这里插入图片描述

栈溢出:

在这里插入图片描述

栈:先进后出,后进先出

队列:先进先出( FIFO : First Input First Output )

栈:**栈内存,主管程序的运行,生命周期和线程同步; **

线程结束,栈内存也就是释放,对于栈来说,不存在垃圾回收问题

一旦线程结束,栈就Over!

栈中放哪些内容:8大基本类型+对象引用+实例的方法

栈运行原理:栈帧

栈满了: StackOverflowError

在这里插入图片描述

​ 栈帧图解

栈 + 堆 +方法区:交互关系

在这里插入图片描述

在这里插入图片描述

作业:在内存中画出一个对象的实例化过程

Java对象在内存中实例化的过程

在讲 Java 对象在内存中的实例化过程前,先来说下在类的实例化过程中,内存会使用到的三个区域:栈区、堆区、方法区。

堆区:
存储的全部都是对象,每个对象包含了一个与之对应的 class 类的信息。
jvm 只有一个堆区(steap),它会被所有线程共享,堆中不存放基本数据类型和对象引用,它只存放对象本身。
栈区:
每个线程都包含一个栈区,栈中只保存基本数据类型的值和对象以及基础数据的引用。
每个栈中的数据(基本数据类型和对象的引用)都是私有的,其它栈是无法进行访问的。
栈分为三个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。
方法区:
又被称为静态区,它跟堆一样,被所有的线程共享,方法区包含所有的 class 信息 和 static修饰的变量。
方法区中包含的都是整个程序中永远唯一的元素,如:class、static变量。

简谈 JDK8 前后 JVM 内存变化

JDK8 之前对内存划分为:新生代(YOUNG)—老年代(Tenured)—永久代(PermGen)
新生代:
新生代又分为伊甸区(Eden) 存活区(Survivor),其中存活区又分为两个大小空间一样的s0、s1,而且s0 和 s1 可以互相转化,存活区保存的一定是在伊甸区保存了很久的,并且经过好几次小的GC还存活下来的对象,存活区一定会有两块大小相等的空间。目的是一块存活区未来的晋升,另一块存活区是为了对象的回收。需要注意的是:这两块存活区一定有一块是空的。

新生代中的 GC:
新生代大小(PSYoungGen total 9216K)=eden大小(eden space 8192K)+1个survivor大小(from space 1024K)

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8(Eden):1(一个survivor),一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到老年代中。
  因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到老年代中。

为什么要设置两个Survivor区?
设置两个Survivor区最大的好处就是解决了碎片化;
假设现在只有一个survivor区,我们来模拟一下流程:
刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。

碎片化带来的风险是极大的,严重影响Java程序的性能。堆空间被散布的对象占据**不连续的内存,**最直接的结果就是,堆中没有足够大的连续内存空间,接下去如果程序需要给一个内存需求很大的对象分配内存。。。画面太美不敢看。。。这就好比我们上学时背包里所有东西紧挨着放,最后就可能省出一块完整的空间放饭盒。如果每件东西之间隔一点空隙乱放,很可能最后就要手提一路了。

1.1、Java 中的数据类型

Java 中的数据类型有两种:

1、基本类型(primitive types): 共有8种,即:int、short、long、byte、char、float、double、boolean(注意并没有 String 的基本类型),这8中类型的定义是通过诸如:int a = 5;long b = 22L;的形式来定义的,称为自动变量。

注意:自动变量存的是字面值,不是类的实例,即不是类的引用,这里并没有类的存在;

如:int a = 5; 这里的 a 是一个指向 int 类型的引用,指向 5 这个字面值,这些字面值的数据由于大小可知,生存期可知( 这些字面值固定定义在某个程序块里面,程序块退出后,字段值就消失了 ),出于追求速度的原因,这些字面值就存在于栈区中;

另外,栈有一个很重要的特殊性,就是存在栈中的数据可以共享。假设我们同时定义
   int a = 3;
   int b = 3;
   编译器先处理int a = 3;首先它会在栈中创建一个变量为a的引用,然后查找有没有字面值为3的地址,没找到,就开辟一个存放3这个字面值的地址,然后将a指向3的地址。接着处理int b = 3;在创建完b的引用变量后,由于在栈中已经有3这个字面值,便将b直接指向3的地址。这样,就出现了a与b同时均指向3的情况。

特别注意的是:这种字面值的引用与类对象的引用不同。假定两个类对象的引用同时指向一个对象,如果一个对象引用变量修改了这个对象的内部状态,那么另一个对象引用变量也即刻反映出这个变化。相反,通过字面值的引用来修改其值,不会导致另一个指向此字面值的引用的值也跟着改变的情况。如上例,我们定义完a与 b的值后,再令a=4;那么,b不会等于4,还是等于3。在编译器内部,遇到a=4;时,它就会重新搜索栈中是否有4的字面值,如果没有,重新开辟地址存放4的值;如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响到b的值。

2、 包装类数据:如:String、Integer、Double等将相应的基本数据类型包装起来的类,这些数据全部存放在 堆 中, Java 用 new()语句来显示地告诉编译器,在运行时才根据需要动态创建,因此比较灵活,但缺点是要占用更多的时间。

1.2、类实例化时内存中发生的变化

首先我们先对下面的代码进行分析:

public class People{
    String name; // 定义一个成员变量 name
    int age; // 成员变量 age
    Double height; // 成员变量 height
    void sing(){
        System.out.println("人的姓名:"+name);
        System.out.println("人的年龄:"+age);
        System.out.println("人的身高:"+height);
    }
    
    public static void main(String[] args) {
        String name; // 定义一个局部变量 name
    	int age; // 局部变量 age
    	Double height; // 局部变量 height
        
        People people = new People() ; //实例化对象people
        people.name = "张三" ;       //赋值
        people.age = 18;             //赋值
        people.stuid = 180.0 ;   //赋值
        people.sing();              //调用方法sing
    }
}

代码解析

这段代码首先定义三个成员变量:String name、int age、Double height 这三个变量都是只声明了没有初始化,然后定义了一个成员方法 sing();

在 main()方法里同样定义了三个一样的变量,只不过这些是局部变量;

在main() 函数里实例化对象 people , 内存中在堆区内会给实例化对象 people 分配一片地址,紧接着我们对实例化对象 people 进行了赋值。people 调用成员方法 sing() 。mian()函数打印输入人的姓名,人的年龄和人的身高,系统执行完毕。

下面通过图解法展示实例化对象的过程中内存的变化

在这里插入图片描述

在程序的执行过程中,首先类中的成员变量和方法体会进入到方法区,如图:

在这里插入图片描述

程序执行到 main() 方法时,main()函数方法体会进入栈区,这一过程叫做进栈(压栈),定义了一个用于指向 Person 实例的变量 person。如图:

在这里插入图片描述

程序执行到 Person person = new Person(); 就会在堆内存开辟一块内存区间,用于存放 Person 实例对象,然后将成员变量和成员方法放在 new 实例中都是取成员变量&成员方法的地址值 如图:

在这里插入图片描述

接下来对 person 对象进行赋值, person.name = “小二” ; perison.age = 13; person.height= 180.0;

先在栈区找到 person,然后根据地址值找到 new Person() 进行赋值操作。

如图:

在这里插入图片描述

当程序走到 sing() 方法时,先到栈区找到 person这个引用变量,然后根据该地址值在堆内存中找到 new Person() 进行方法调用。

在方法体void speak()被调用完成后,就会立刻马上从栈内弹出(出站 )

最后,在main()函数完成后,main()函数也会出栈 如图:

在这里插入图片描述

以上就是Java对象在内存中实例化的全过程。

10.三种JVM

看自己的是哪个版本:java -version

在这里插入图片描述

在这里插入图片描述

我们学习的都是HotSpot

11.堆

Heap, 一个JVM只有一个堆内存,堆内存的大小是可以调节的。

类加载器读取了类文件后,一般会把什么东西放到堆中? 类, 方法,常量,变量~,保存我们所有引用类型的真实对象;

堆内存中还要细分为三个区域:

​ ●新生区(伊甸园区) Young/New
●养老区old
●永久区Perm jdk8之后叫做 “元空间”

在这里插入图片描述

在这里插入图片描述

GC垃圾回收, 主要在伊甸园区和养老区~

假设内存满了,OOM内存不够了! java.lang.OutOfMemoryError: java heap space

12.新生区、老年区

新生区

类 : 诞生和成长的地方 , 甚至死亡;

伊甸园, 所有的对象都是在伊甸园区new出来的

幸存区(0,1)0区1区

在这里插入图片描述

伊甸园满了就触发轻GC,经过轻GC存活下来的就到了幸存者区,幸存者区满之后意味着新生区也满了,则触发重GC,经过重GC之后存活下来的就到了养老区。

真理:经过研究,99%的对象都是临时对象!|

13.永久区

​ 这个区域常驻内存的. 用来存放jdk自身携带的Class对象。Interface元数据,存储的是java运行时的一些环境或类信息,这个区域不存在垃圾回收!关闭vm虚拟就会释放这个区域的内存。

一个启动类,加载了大量的第三方jar包。Tomcat部署了太多应用,大量动态生成的反射类。不断的被加载。直到内存满,就会出现OOM(OutOfMemoryError表示堆内存溢出)。

  • jdk1.6之前 :永久代,常量池是在方法区;
  • jdk1.7 :永久代,但是慢慢退化了, 去永久代,常量池在堆中;
  • jdk1.8之后:无永久代, 常量池在元空间;

在这里插入图片描述

元空间:逻辑上存在,物理上不存在

14.堆内存调优

默认情况下: 分配的总内存 是电脑内存的1/4,而初始化的内存: 1/64

OOM:堆内存溢出 解决办法

  1. 尝试扩大堆内存看结果

    • -Xms1024m -Xmx1024m -xx:+PrintGCDetails (idea中运行时VM options参数)

      jvm参数

      -Xss(StackSpace)栈空间

      -Xms ,-Xmx(heap memory space)堆空间:Heap是大家最为熟悉的区域,他是jvm用来存储对象实例的区域,Heap在32位的系统中最大为2G,其大小通过-Xms和-Xmx来控制,-Xms为jvm启动时申请的最小Heap内存,默认为物理内存的1/64,但小于1G,-Xmx为jvm可申请的最大的Heap内存,默认为物理内存的1/4,一般也小于1G。

  2. 分析内存,看一下哪个地方出现了问题 (专业工具)

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

(轻GC重GC都满了才出现错误)

在一个项目中,突然出现了OOM故障,那么该如何排除~研究为什么出错
  • 能够看到代码第几行出错:内存快照分析工具,MAT,Jprofiler
  • Dubug,一行行分析代码

MAT是最早eclipse集成使用,在这里不学

MAT,Jprogiler作用:
  • 分析Dump内存文件,快速定位内存泄漏
  • 获得堆中的数据
  • 获得大的对象

Jprofile使用

Jprofiler安装过程

idea中安装插件Plugins中搜索Jprofiler

重启idea

安装客户端jprofiler

上网找安装包,安装很简单

在这里插入图片描述

注册(前两个随便输,激活码网上搜)

在这里插入图片描述

idea中配置在这里插入图片描述

jprofiler使用

-Xms 设置初始化内存分配大小 1/64

-Xmx 设置最大分配内存,默认1/4

-XX:+HeapDumpOnOutOfMemoryError oomDump

在这里插入图片描述

看这个

在这里插入图片描述

在这里插入图片描述

1.在idea中下载jprofile插件

2.联网下载jprofile客户端

3.在idea中VM参数中写参数 -Xms1m -Xmx8m -XX: +HeapDumpOnOutOfMemoryError

4.运行程序后在jprofile客户端中打开找到错误 告诉哪个位置报错

命令参数详解

-Xms 设置初始化内存分配大小1/64

-Xmx 设置最大分配内存,默以1/4

-XX: +PrintGCDetails 打印GC垃圾回收信息

-XX: +HeapDumpOnOutOfMemoryError oom DUMP

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

生成的文件在当前目录下找

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

执行了6次出现错误

Exception捕获不到,Error能捕获到

dump之后会产生很多文件,删掉即可

15. GC垃圾回收

GC:作用区域 堆+方法区

在这里插入图片描述

JVM在进行GC时,并不是对这三个区域统一回收。大部分时候,回收都是新生代~

  • 新生代
  • 幸存区(form , to)
  • 老年区

GC两种:轻GC(普通GC),重GC(全局GC)

GC题目

  • JVM的内存模型和分区~详细到每个区放什么?
  • 堆里面的分区有哪些? Eden,form,to,老年区,说说他们的特点?
  • GC的算法有哪些? 标记清除法 , 标记整理 , 复制算法, 引用计数器, 怎么用的?
  • 轻GC和重GC分别在什么时候发生?

GC常用算法

引用计数法(不常用)

在这里插入图片描述

对象A用了1次加1,对象B用了2次加2,对象C为0淘汰掉

复制算法

在这里插入图片描述

当一个对象经历了15次GC,都还没有死,进入养老区。

-XX:MaxTenuringThreshold设置的是年龄阈值,默认15(对象被复制的次数)

上边年龄设置为9999,对象基本不会进入养老区,就全死了。

复制算法图解 在这里插入图片描述

  • 好处:没有内存的碎片
  • 坏处:浪费了内存空间,多了一半空间永远是空 to.假设对象100%存活(极端情况)
  • 复制算法最佳使用场景:对象存活度较低的时候,新生区;新生区使用这个算法

有几种情况,对象会晋升到老年代:

  • 超大对象会直接进入到老年代(受虚拟机参数-XX:PretenureSizeThreshold参数影响,默认值0,即不开启(默认值是0,意思是不管多大都是先在eden中分配内存),单位为Byte,例如:3145728=3M,那么超过3M的对象,会直接晋升老年代)

  • 如果to区已满,多出来的对象也会直接晋升老年代

  • 复制15次(15岁)后,依然存活的对象,也会进入老年代

此时eden区和from区都是垃圾对象,可以直接清除。

复制算法会将内存空间分为两块,每次只使用其中一块内存。复制算法同样使用可达性分析法标记除垃圾对象,当GC执行时,会将非垃圾对象复制到另一块内存空间中,并且保证内存上的连续性,然后直接清空之前使用的内存空间。然后如此往复。

我们姑且将这两块内存区域称为from区和to区。

如下图所示,r1和r2作为GC Root对象,经过可达性分析后,标记除黄色对象为垃圾对象。

img

复制过程如下,GC会将五个存活对象复制到to区,并且保证在to区内存空间上的连续性。

img

最后,将from区中的垃圾对象清除。

img

综上述,该算法在存货对象少,垃圾对象多的情况下,非常高效。其好处是不会产生内存碎片,但坏处也是显而易见的,就是直接损失了一半的可用内存。

标记清除算法

在这里插入图片描述

  • 优点:不需要额外的空间

  • 缺点:两次扫描,严重浪费时间,会产生内存碎片

标记压缩算法(优化标记清除)

在这里插入图片描述

三部曲:标记,清除,压缩

总结:

如何选择四种算法:

内存效率:复制算法>标记清除算法>标记压缩算法(时间复杂度)

内存整齐度:复制算法=标记压缩算法>标记清除算法

内存利用率:标记压缩算法=标记清除算法>复制算法

思考:难道没有一个最优的算法吗?

答案:没有,更确切的说是没有最好的算法,只有最合适的算法---->GC:(也被称为)分带收集算法

年轻代:

​ 存活率低

​ 用复制算法

老年代:

​ 区域大:存活率高

​ 用标记清除(内存碎片不是太多)+标记压缩混合实现

JMM

1.什么是JMM?

JMM :(java Memory Model 缩写)java内存模型

2.他是干什么的?

在这里插入图片描述

在这里插入图片描述

解决共享对象可见性的问题:volilate

3.他该如何学习

JMM :抽象的概念,理论

在这里插入图片描述

store 用来给properties添加新的键值对。

在这里插入图片描述

JMM简介

Java的内存模型JMM(Java Memory Model)

JMM主要是为了规定了线程内存之间的一些关系。根据JMM的设计,系统存在一个主内存(Main Memory),Java中所有实例变量都储存在主存中,对于所有线程都是共享的。每条线程都有自己的工作内存(Working Memory),工作内存由缓存和堆栈两部分组成,缓存中保存的是主存中变量的拷贝,缓存可能并不总和主存同步,也就是缓存中变量的修改可能没有立刻写到主存中;堆栈中保存的是线程的局部变量,线程之间无法相互直接访问堆栈中的变量。

JMM是什么

JMM (Java Memory Model)是**Java内存模型,**JMM定义了程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存读取变量这样的底层细节。 为什么要设计JMM 屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

为什么要理解JMM

主内存,工作内存和线程三者的交互关系

JMM规定了共享变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存保存了主内存的副本拷贝,对变量的操作在工作内存中进行,不能直接操作主内存中的变量。不同线程间无法直接访问对方的工作内存变量,需要通过主内存完成。如下图:

img

JMM学习

JMM(Java Memory Model)

JMM是什么?

JMM是Java的并发采用的是共享内存模型,是不存在的东西是一种概念与约定。

关于JMM的约定有以下几点

  • 线程加锁解锁都是同一把锁
  • 线程加锁前必须读取主存中最新值到工作内存中
  • 线程解锁前必须把共享变量刷新到主存中

JMM长啥样?

在这里插入图片描述

从上图可以看出JMM定义了线程与内存之间的抽象关系,线程之间的共享变量存储在内存中,每一个线程都有一个私有的本地内存,在本地内存中存储该线程的读写副本。并且线程是不直接操作内存,只能操作自己工作内存中的变量后将其刷新到内存中。内存是多个线程共享而线程自己工作内存由线程独享。当线程需要通信时需借助内存来完成。

在图中可以看到8种操作这是与内存的交互操作,每一个都是原子性的不可再分的

  • lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
  • read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
  • load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
  • use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需使用这个变量的指令时候,将会执行这个动作;
  • assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
  • store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
  • write(写入):作用于主内存,它把store传送值放到主内存中的变量中。
  • unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定

JMM(Java内存模型)还规定了执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现(即不允许一个变量从主存读取了但是工作内存不接受,或者从工作内存发起会写了但是主存不接受的情况),以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。

问题来了,JMM是如何解决可见性问题的?

问题1:当两个线程同时操作一个共享变量时,其中一个线程修改后另一个线程知道吗?

下面我们用代码来看看

package com.kowk.jmm;
import java.util.concurrent.TimeUnit;
/**
 * @ClassName VolatileTest
 * @Description 
 * 
 *思路:
 *  定义两个线程与一个共享的静态变量,
 *  线程A根据变量无限循环,线程B在几秒后将变量改变
 *  如果程序停止说明线程A可以知道线程B对变量做出修改,如果不结束则不知道
 *  
 * @Author kwok
 * @Version 1.0
 */
public class VolatileTest01 {

    // 定义一个静态变量
    private static int num = 0;

    public static void main(String[] args) {
        
        // 新建一个线程进入无限循环
        new Thread(()->{
            while (num == 0) {

            }
        }).start();
        // 主线程延迟两秒使效果明显
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 改变变量,使线程A中判断状态改变
        num = 1;
        // 输出变量,确认修改情况
        System.out.println(num);
    }
}

结果是输出1后程序还没结束,说明线程A不知道线程B做出了改变。

如何解决这个不可见性问题也是同步问题

这时就是我们的volatile关键字出场了

那volatile是什么?

volatile是java虚拟机提供的轻量级的同步机制

volatile有三大特点:

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

保证可见性

普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新。哦豁这就相当于变量改变后就通知其他使用这个变量的线程“喂,这个变量变了,你手上的out了,快去重新领一个”。下面再测试,将普通变量变成volatile就行

private volatile static int num = 0;

再次运行完美解决。

不保证原子性

volatile既然保证了可见性但为啥不保证原子性?要知道java中只有基本操作例如num = 1;这样才是原子操作。而像i++这样的操作,其过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写会到缓存中。所以就变成不是原子性操作,但这样有啥问题?下面用代码来说明

  /**
     * @ClassName VolatileTEst02
     * @Description
     *
     * 思路:
     *  用10个线程进行100次++操做后看结果,
     *  如果结果如预期所料为1000即保证原子性,否则不保证
     *
     * @Author kwok
     * @Version 1.0
     */
    public class VolatileTEst02 {
    
        // 定义一个volatile修饰的静态变量
        private volatile static int num = 0;
        
        public static void main(String[] args) {
    
            // 用10条线程分别对num进行++操作
            for (int i = 0; i < 10; i++) {
                new Thread(()->{
                    for (int i1 = 0; i1 < 100; i1++) {
                        // ++操作不是原子性操作,从字节码文件可以看出其分为3步
                        num++;
                    }
                }).start();
            }
            // 这里判断存活的线程是否只有主线程,如果不是主线程让步给其他线程
            while (Thread.activeCount() > 2) {
                Thread.yield();
    
            }
            // 打印结果
            System.out.println(num);
        }
    }

运行可得每次结果都不一样,说明volatile不保证,里面是为何?

上面说了num++操作分成3个步骤首先获取num的值,其次对num的值进行加1,最后将得到的新值写会到缓存中。当线程A获取这个num时获取最新值但还没来得及修改,这个时候阻塞了,线程B获取并修改了num并刷回内存,但是!线程A读取num的原子性操作已经结束所以线程A中num不会失效,哦豁这样问题就出现了。这里只是对这个不保证原子性做解释,如果想解决可以参考juc包中的atomic包中方法。

禁止指令重排

指令重排是什么?

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排列的一种手段。一份代码会经历下面几个阶段。

在这里插入图片描述

  • 编译器重排序:编译器在不影响单条线程的执行结果的情况下对代码行重排排列。
  • 指令重排序:计算机的操作都是由一条条指令组成,我们代码运行进行操作最终也是指令,这里与编译器同理。
  • 内存:因为有读写缓存,加载存储可能是乱序的。

这里看个例子

int a = 1;
int b = 2;
a = a + 5;
b = a + a;

// 正常结果是a = 6;  b = 12;

//当重排序后顺序变成1243 就变成 a = 6; b = 2;

当然单线程正常不会发生,下面看多线程

在这里插入图片描述

线程在自己里面改变顺序没影响自己的结果吧,但刷新后就会改变其他线程了。

这其实涉及到数据依赖性的问题。

什么是数据依赖性?

数据依赖性就是两个数据相互之间有联系如上诉例子,也可以说两个相关的操作包含了写操作就是有数据依赖性,这时就不能让他们重排序。

这里规定了一个as-if-serial 语义

  • 含义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变
  • 编译器、runtime 和处理器都必须遵守 as-if-serial 语义
  • 编译器和处理器不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果
  • 如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序

volatile怎么做的

  • 保证内存的可见性,如果A事件发生在B事件后,那B一定对A可见。
  • volatile在该声明了volatile操作上下各做了一层内存屏障,通俗来说内存屏障就是告诉计算机这里的指令不能进行交换排序,这就解决了重排序可能会发生的问题

下面是内存屏障的四种类型:

屏障类型指令示例说明
LoadLoadBarriersLoad1;LoadLoad;Load2Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStoreBarriersStore1;StoreStore;Store2Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见
LoadStoreBarriersLoad1;LoadStore;Store2在Store2被写入前,保证Load1要读取的数据被读取完毕。
StoreLoadBarriersStore1;StoreLoad;Load2在Load2读取操作执行前,保证Store1的写入对所有处理器可见

StoreLoad Barriers是一个全能型的屏障,同时具有其他3个屏障的效果。执行该屏障的花销比较昂贵,因为处理器通常要把当前的写缓冲区的内容全部刷新到内存中(Buffer Fully Flush)。

volatile写前保证普通写可见,写后保证对后续可见

在这里插入图片描述

volatile读保证读取完整

在这里插入图片描述

JMM(JAVA Memory Model)学习

JMM(java内存模型JAVA Memory Model,简称JMM)本身是一种抽象的概念 并不真实存在,他描述的是一组规则或规范,通过这组规范,定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

JMM关于同步的规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁是同一把锁

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在 主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的 变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

img

JMM特性

  • 可见性
  • 原子性
  • 有序性

特性解释

  • 可见性:一个线程操作某个对象,修改了值之后,其余的线程都知道,这就叫可见性

各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后再写回到主内存中的。

这就可能存在一个线程AAA修改了共享变量X的值,但还未写回主内存时,另外一个线程BBB又对主内存中同一个变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说并不可见

这种工作内存与主内存同步延迟现象就造成了可见性问题

  • 原子性:

一个线程在操作某个对象时,对象的值发生更改,并更新回主内存,其余线程还未获得更新的消息,将值同样更新回主内存,造成数据丢失的情况,即不保证原子性

  • 禁止指令重排

计算机执行程序时,为了提高性能,编译器和处理器常常会对 指令做重排

源代码 ==》编译器优化的重排 ==》指令并行的重排 ==》 内存系统的重排 ==》最终执行的指令

单线程环境里面确保程序最终执行和代码顺序执行的结果一致

处理器在进行重排序时,必须要考虑指令之间的 数据依赖性

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测

了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,他的作用有两个:

  • 一是保证特定操作的执行顺序
  • 而是保证某些变量的内存可见性(利用该特性,实现volatile的内存可见性)

由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条Memory Barrier,则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier 指令重排序,也就是说 通过插入内存屏障,禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐