15_JVM入门

1、JVM的位置

2、JVM的体系结构

3、类加载器

  • 作用:加载Class文件

  • 加载器分类

    1. 虚拟机自带的加载器
    2. 启动类(根)加载器 (Bootstrap ClassLoader)
    3. 扩展类加载器 (Extension ClassLoader)
    4. 应用程序加载器(Application ClassLoader)

4、双亲委派机制

检查顺序从下至上,加载顺序从上到下。

如果一个类加载器需要加载类,那么首先它会把这个类请求委派给父类加载器去完成,每一层都是如此。一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。

//双亲委派机制:安全
// 1. APP–>EXC—B0OT(最终执行)
// B0OT
// EXC
// APP
 1.类加载器收到类加载的请求
 2.将这个请求向上委托给父类加载器去完成,一直向上委托,直到启动类加载器
 3.启动加载器检查是否能够加载当前这个类,能加载就结束, 使用当前的加载器,否则, 抛出异常,通知子加载器进行加载
 4.重复步骤3
 Class Not Found异常就是这么来的
 Java早期的名字:C+±-
 Java = C++:去掉繁琐的东西,指针,内存管理~

5、沙箱安全机制

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沙箱起作用

    • 它防止恶意代码去干涉善意的代码
    • 它守护了被信任的类库边界
    • 它将代码归入保护域,确定了代码可以进行哪些操作

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

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

    1. 从最内层JVM自带类加载器开始加载,外层恶意同名类得不到加载从而无法使用
    2. 由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就自然无法生效。
  • 存取控制器(access controller) :存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定,可以由用户指定。

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

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

    • 安全提供者
    • 消息摘要
    • 数字签名
    • 加密
    • 鉴别

6、Native

  • 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

方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间。
静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关

9、栈

栈:先进后出。

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

喝多了吐就是栈,吃多了拉就是队列

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

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

栈内存中一般有 9大基本类型 + 对象引用 + 实例的方法

栈运行原理:栈帧

下图为内存中的栈的示意图:

栈帧图解,栈底部子帧指向上一个栈的方法,上一个栈的父帧指向栈底部方法,如下图:

10、三种JVM

  • Sun公司HotSpot Java Hotspot™ 64-Bit Server VM (build 25.181-b13,mixed mode)
  • BEA JRockit
  • IBM J9VM

我们学习都是: Hotspot

11、堆

11.1、概述

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

类加载器读取了类文件后,一般会把什么东西放到堆中?

类, 方法,常量,变量~,保存我们所有引用类型的真实对象

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

  • 新生区(伊甸园区) Young/New
  • 养老区old
  • 永久区Perm

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

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

永久存储区里存放的都是Java自带的,例如lang包中的类,如果不存在这些,Java就跑不起来了
在JDK8以后,永久存储区改了个名字(元空间)

11.2、新生区、老年区

  • 类诞生和成长的地方,甚至死亡
  • 伊甸园,所有的对象都是在伊甸园区new出来的
  • 幸存者区(0,1)

伊甸园满了就触发轻GC,经过轻GC存活下来的就到了幸存者区,幸存者区满之后意味着新生区也满了,则触发重GC,经过重GC之后存活下来的就到了养老区。
真理:经过研究,99%的对象都是临时对象!|

11.3、永久区

这个区域常驻内存的。用来存放JDK自身携带的Class对象。Interface元数据,存储的是Java运行时的一些环境~ 这个区域不存在垃圾回收,关闭虚拟机就会释放内存

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

元空间:逻辑上存在,物理上不存在 (因为存储在本地磁盘内) 所以最后并不算在JVM虚拟机内存中

11.4、堆内存调优

测试代码:

public static void main(String[] args) {
    String s = "";
    while (true) {
        s += "11111111111111111111111111111111111111111111111111111";
    }
}

在一个项目中,突然出现了OOM故障,那么该如何排除 研究为什么出错~

  • 能够看到代码第几行出错:内存快照分析工具,MAT、 Jprofiler
  • Debug, 一行行分析代码

MAT、 Jprofiler作用:

  • 分析Dump内存文件,快速定位内存泄露
  • 获得堆中的数据
  • 获得大的对象~

Jprofile使用

  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 导出内存错误的文件

代码演示:

public class Test {
    public static void main(String[] args) {
        List<Test> list = new ArrayList<>();
        int count = 0;
        try {
            while (true) {
                list.add(new Test());
                count += 1;
            }
        } catch (Error e) {
            System.out.println("count:" + count);
            e.printStackTrace();
        }
    }
}

12、GC

12.1、概述

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

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

GC两种类:

  • 轻GC(普通的GC)
  • 重GC(全局GC)

GC常见面试题:

  • JVM的内存模型和分区,详细到每个区放什么?

  • 堆里面的分区有哪些?并说说他们的特点

    Eden、from、to、老年区

  • GC的算法有哪些?

    标记清除法、标记压缩、复制算法、引用计数器

  • 轻GC和重GC分别在什么时候发生?

12.2、GC的常用算法

引用计数法:

通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数+1,如果删除对该对象的引用,那么它的引用计数就-1,当该对象的引用计数为0时,那么该对象就会被回收。

复制算法:

将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象交换两个内存的角色,完成垃圾回收。

流程:

  1. 将Eden区进行GC存活对象放入空的to区,将from区存活的放到空的to区
  2. 此时from区为空变成了to区,to区有数据变为from区
  3. 经过15次GC,from区还存活的对象会被移动到养老区

优点:没有内存碎片

缺点:浪费了内存空间(to区为空),多了一半空间永远是空to。假设对象100%存活(极端情况)

复制算法最佳使用场景:对象存活度较低的时候;新生区~

标记清除法:

标记-清除算法是现代垃圾回收算法的思想基础。标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象;然后,在清除阶段,清除所有未被标记的对象。

需要两次扫描,第一次扫描标记存活对象,第二次扫描清除没有被标记的对象

优点:不需要额外的空间

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

标记压缩:

标记-压缩算法适合用于存活对象较多的场合,如老年代。它在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记;但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端;之后,清理边界外所有的空间。

总结:

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

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

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

没有最好的算法,只有最合适的算法 ---> GC:分代收集算法

年轻代:存活率低,使用复制算法

老年代:区域大,存活率高,使用标记清除(内存碎片不是太多) + 标记压缩混合实现

13、JMM

13.1、定义

JMM即Java内存模型(Java memory model),在JSR133里指出了JMM是用来定义一个一致的、跨平台的内存模型,是缓存一致性协议,用来定义数据读写的规则。

13.2、内存可见性

在Java中,不同线程拥有各自的私有工作内存,当线程需要读取或修改某个变量时,不能直接去操作主内存中的变量,而是需要将这个变量读取到线程的工作内存变量副本中,当该线程修改其变量副本的值后,其它线程并不能立刻读取到新值,需要将修改后的值刷新到主内存中,其它线程才能从主内存读取到修改后的值

13.3、指令重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,指令重排序使得代码在多线程执行时会出现一些问题。

其中最著名的案例便是在初始化单例时由于可见性重排序导致的错误。

  • 单例模式

    • 案例1

      public class Singleton {
          private static Singleton singleton;
          private Singleton() {
          }
          public static Singleton getInstance() {
              if (singleton == null) {
                  singleton = new Singleton();
              }
              return singleton;
          }
      }
      

      以上代码是经典的懒汉式单例实现,但在多线程的情况下,多个线程有可能会同时进入if (singleton == null) ,从而执行了多次singleton = new Singleton(),从而破坏单例。

    • 案例2

      public class Singleton {
          private static Singleton singleton;
          private Singleton() {
          }
          public static Singleton getInstance() {
              if (singleton == null) {
                  synchronized (Singleton.class) {
                      if (singleton == null) {
                          singleton = new Singleton();
                      }
                  }
              }
              return singleton;
          }
      }
      

      以上代码在检测到singleton为null后,会在同步块中再次判断,可以保证同一时间只有一个线程可以初始化单例。但仍然存在问题,原因就是Java中singleton = new Singleton()语句并不是一个原子指令,而是由三步组成:

      1. 为对象分配内存
      2. 初始化对象
      3. 将对象的内存地址赋给引用

      但是当经过指令重排序后,会变成:

      1. 为对象分配内存
      2. 将对象的内存地址赋给引用(会使得singleton != null)
      3. 初始化对象

      所以就存在一种情况,当线程A已经将内存地址赋给引用时,但实例对象并没有完全初始化,同时线程B判断singleton已经不为null,就会导致B线程访问到未初始化的变量从而产生错误。

    • 案例3

      public class Singleton {
          private static volatile Singleton singleton;
          private Singleton() {
          }
          public static Singleton getInstance() {
              if (singleton == null) {
                  synchronized (Singleton.class) {
                      if (singleton == null) {
                          singleton = new Singleton();
                      }
                  }
              }
              return singleton;
          }
      }
      

      以上代码对singleton变量添加了volatile修饰,可以阻止局部指令重排序

13.4、volatile

  • 原理
    1. 规定线程每次修改变量副本后立刻同步到主内存中,用于保证其它线程可以看到自己对变量的修改
    2. 规定线程每次使用变量前,先从主内存中刷新最新的值到工作内存,用于保证能看见其它线程对变量修改的最新值
    3. 为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障防止指令重排序
  • 注意
    1. volatile只能保证基本类型变量的内存可见性,对于引用类型,无法保证引用所指向的实际对象内部数据的内存可见性。
    2. volilate只能保证共享对象的可见性,不能保证原子性:假设两个线程同时在做x++,在线程A修改共享变量从0到1的同时,线程B已经正在使用值为0的变量,所以这时候可见性已经无法发挥作用,线程B将其修改为1,所以最后结果是1而不是2。
posted @ 2021-08-13 23:26  蓝色空间号  阅读(41)  评论(0)    收藏  举报