当前位置: 首页 > news >正文

企业网站制作一般多少钱做ppt的兼职网站有哪些

企业网站制作一般多少钱,做ppt的兼职网站有哪些,jsp网站开发英文文献,个人站长网站1 初始JVM 1.1 什么是JVM JVM 全称是 Java Virtual Machine#xff0c;中文译名 Java虚拟机。JVM 本质上是一个运行在计算机上的程序#xff0c;他的职责是运行Java字节码文件。 Java源代码执行流程如下#xff1a; 分为三个步骤#xff1a; 编写Java源代码文件。 使用…1 初始JVM 1.1 什么是JVM JVM 全称是 Java Virtual Machine中文译名 Java虚拟机。JVM 本质上是一个运行在计算机上的程序他的职责是运行Java字节码文件。 Java源代码执行流程如下 分为三个步骤 编写Java源代码文件。 使用Java编译器javac命令将源代码编译成Java字节码文件。 使用Java虚拟机加载并运行Java字节码文件此时会启动一个新的进程。 1.2 JVM的功能 解释和运行内存管理即时编译 1.2.1 解释和运行 对字节码文件中的指令实时的解释成机器码让计算机执行。 字节码文件中包含了字节码指令计算器无法直接执行Java虚拟机会将字节码文件中的字节码指令实时地解释成机器码机器码是计算机可以运行的指令。 1.2.2 内存管理 自动为对象、方法等分配内存自动的垃圾回收机制回收不再使用的对象 Java虚拟机会帮助程序员为对象分配内存同时将不用的对象使用垃圾回收器回收掉这是对比C和C这些语言的一个优势。在C/C语言中对象的回收需要程序员手动去编写代码完成如果遗漏了这段删除对象的代码这个对象就会永远占用内存空间不会再回收。所以JVM的这个功能降低了程序员编写代码的难度。 1.2.3 即时编译 对热点代码进行优化提升执行效率。即时编译可以说是提升Java程序性能最核心的手段。 Java性能低的主要原因和跨平台特性 Java语言如果不做任何的优化性能其实是不如C和C语言的。主要原因是 在程序运行过程中Java虚拟机需要将字节码指令实时地解释成计算机能识别的机器码这个过程在运行时可能会反复地执行所以效率较低。 C和C语言在执行过程中只需要将源代码编译成可执行文件就包含了计算机能识别的机器码无需在运行过程中再实时地解释所以性能较高。 Java为什么要选择一条执行效率比较低的方式呢主要是为了实现跨平台的特性。Java的字节码指令如果希望在不同平台操作系统硬件架构比如在windows或者linux上运行。可以使用同一份字节码指令交给windows和linux上的Java虚拟机进行解释这样就可以获得不同平台上的机器码了。这样就实现了Write OnceRun Anywhere 编写一次到处运行 的目标。 但是C/C语言如果要让程序在不同平台上运行就需要将一份源代码在不同平台上分别进行编译相对来说比较麻烦。 再回到即时编译在JDK1.1的版本中就推出了即时编译去优化对应的性能。 虚拟机在运行过程中如果发现某一个方法甚至是循环是热点代码被非常高频调用即时编译器会优化这段代码并将优化后的机器码保存在内存中如果第二次再去执行这段代码。Java虚拟机会将机器码从内存中取出来直接进行调用。这样节省了一次解释的步骤同时执行的是优化后的代码效率较高。 Java通过即时编译器获得了接近C/C语言的性能在某些特定的场景下甚至可以实现超越。 1.3 常见的JVM 1.3.1 Java虚拟机规范 《Java虚拟机规范》由Oracle制定内容主要包含了Java虚拟机在设计和实现时需要遵守的规范主要包含class字节码文件的定义、类和接口的加载和初始化、指令集等内容。《Java虚拟机规范》是对虚拟机设计的要求而不是对Java设计的要求也就是说虚拟机可以运行在其他的语言比如Groovy、Scala生成的class字节码文件之上。官网地址https://docs.oracle.com/javase/specs/index.html 1.3.2 Java虚拟机规范 平时我们最常用的就是Hotspot虚拟机。 名称作者支持版本社区活跃度github star特性适用场景HotSpot (Oracle JDK版)Oracle所有版本高(闭源)使用最广泛稳定可靠社区活跃JIT支持Oracle JDK默认虚拟机默认HotSpot (Open JDK版)Oracle所有版本中(16.1k)同上开源Open JDK默认虚拟机默认对JDK有二次开发需求GraalVMOracle11, 17,19企业版支持8高18.7k多语言支持高性能、JIT、AOT支持微服务、云原生架构需要多语言混合编程Dragonwell JDK龙井Alibaba标准版 8,11,17扩展版11,17低(3.9k)基于OpenJDK的增强高性能、bug修复、安全性提升JWarmup、ElasticHeap、Wisp特性支持电商、物流、金融领域对性能要求比较高Eclipse OpenJ9 (原 IBM J9)IBM8,11,17,19,20低(3.1k)高性能、可扩展JIT、AOT特性支持微服务、云原生架构 1.3.3 HotSpot的发展历程 初出茅庐 - 1999年4月 源自1997年收购的SmallTalk语言的虚拟机HotSpot虚拟机初次在JDK中使用。在JDK1.2中作为附加功能存在 JDK1.3之后作为默认的虚拟机。 野蛮生长 - 2006年12月 JDK 6发布并在虚拟机层面做了大量的优化这些优化对后续虚拟机的发展产生了深远的影响。 稳步前进 - 2009-2013 JDK7中首次推出了G1垃圾收集器。收购了Sun公司之后吸纳了JRockIt虚拟机的一些设计思想JDK8中引入了JMC等工具去除了永久代。 百家争鸣 - 2018-2019 JDK11优化了G1垃圾收集器的性能,同时推出了ZGC新一代的垃圾回收器JDK12推出Shenan-doah垃圾回收器。 拥抱云原生 - 2019-至今 以Hotspot为基础的GraalVM虚拟机诞生不仅解决了单体应用中多语言整合的难题同时也提升了这些语言运行时的效率。极高的性能、极快的启动速度也更适用于当下的云原生架构。 2 字节码文件详解 2.1 Java虚拟机的组成 Java虚拟机主要分为以下几个组成部分 类加载子系统核心组件类加载器负责将字节码文件中的内容加载到内存中。运行时数据区JVM管理的内存创建出来的对象、类的信息等等内容都会放在这块区域中。执行引擎包含了即时编译器、解释器、垃圾回收器执行引擎使用解释器将字节码指令解释成机器码使用即时编译器优化性能使用垃圾回收器回收不再使用的对象。本地接口调用本地使用C/C编译好的方法本地方法在Java中声明时都会带上native关键字如下图所示。 2.2 字节码文件的组成 2.2.1 以正确的姿势打开文件 字节码文件中保存了源代码编译之后的内容以二进制的方式存储无法直接用记事本打开阅读。 通过NotePad使用十六进制插件查看class文件 无法解读出文件里包含的内容推荐使用 jclasslib工具查看字节码文件。 Github地址 https://github.com/ingokegel/jclasslib 2.2.2 字节码文件的组成 字节码文件总共可以分为以下几个部分 基础信息魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口信息常量池**** 保存了字符串常量、类或接口名、字段名主要在字节码指令中使用字段 当前类或接口声明的字段信息方法 当前类或接口声明的方法信息核心内容为方法的字节码指令属性 类的属性比如源码的文件名、内部类的列表等 基本信息 基本信息包含了jclasslib中能看到的两块内容 Magic魔数每个Java字节码文件的前四个字节是固定的用16进制表示就是0xcafebabe。文件是无法通过文件扩展名来确定文件类型的文件扩展名可以随意修改不影响文件的内容。软件会使用文件的头几个字节文件头去校验文件的类型如果软件不支持该种类型就会出错。 比如常见的文件格式校验方式如下 Java字节码文件中将文件头称为magic魔数。Java虚拟机会校验字节码文件的前四个字节是不是0xcafebabe如果不是该字节码文件就无法正常使用Java虚拟机会抛出对应的错误。 主副版本号主副版本号指的是编译字节码文件时使用的JDK版本号主版本号用来标识大版本号JDK1.0-1.1使用了45.0-45.3JDK1.2是46之后每升级一个大版本就加1副版本号是当主版本号相同时作为区分不同版本的标识一般只需要关心主版本号。 1.2之后大版本号计算方法就是 : 主版本号 – 44比如主版本号52就是JDK8。 版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容。如果使用较低版本的JDK去运行较高版本JDK的字节码文件无法使会显示如下错误 有两种方案 1.升级JDK版本将图中使用的JDK6升级至JDK8即可正常运行容易引发其他的兼容性问题并且需要大量的测试。 2.将第三方依赖的版本号降低或者更换依赖以满足JDK版本的要求。建议使用这种方案 其他基础信息包括访问标识、类和接口索引如下 常量池 字节码文件中常量池的作用避免相同的内容重复定义节省空间。如下图常量池中定义了一个字符串字符串的字面量值为123。 比如在代码中编写了两个相同的字符串“我爱北京天安门”字节码文件甚至将来在内存中使用时其实只需要保存一份此时就可以将这个字符串以及字符串里边包含的字面量放入常量池中以达到节省空间的作用。 String str1 我爱北京天安门; String str2 我爱北京天安门;常量池中的数据都有一个编号编号从1开始。比如“我爱北京天安门”这个字符串在常量池中的编号就是7。在字段或者字节码指令中通过编号7可以快速的找到这个字符串。 字节码指令中通过编号引用到常量池的过程称之为符号引用。 字段 字段中存放的是当前类或接口声明的字段信息。 如下图中定义了两个字段a1和a2这两个字段就会出现在字段这部分内容中。同时还包含字段的名字、描述符字段的类型、访问标识public/private static final等。 方法 字节码中的方法区域是存放字节码指令的核心位置字节码指令的内容存放在方法的Code属性中。 通过分析方法的字节码指令可以清楚地了解一个方法到底是如何执行的。先来看如下案例 int i 0; int j i 1;这段代码编译成字节码指令之后是如下内容 要理解这段字节码指令是如何执行的我们需要先理解两块内存区域操作数栈和局部变量表。 操作数栈是用来存放临时数据的内容是一个栈式的结构先进后出。 局部变量表是存放方法中的局部变量包含方法的参数、方法中定义的局部变量在编译期就已经可以确定方法有多少个局部变量。 1、iconst_0将常量0放入操作数栈。此时栈上只有0。 2、istore_1会从操作数栈中将栈顶的元素弹出来此时0会被弹出放入局部变量表的1号位置。局部变量表中的1号位置在编译时就已经确定是局部变量i使用的位置。完成了对局部变量i的赋值操作。 3、iload_1将局部变量表1号位置的数据放入操作数栈中此时栈中会放入0。 4、iconst_1会将常量1放入操作数栈中。 5、iadd会将操作数栈顶部的两个数据相加现在操作数栈上有两个数0和1相加之后结果为1放入操作数栈中此时栈上只有一个数也就是相加的结果1。 6、istore_2从操作数栈中将1弹出并放入局部变量表的2号位置2号位置是j在使用。完成了对局部变量j的赋值操作。 7、return语句执行方法结束并返回。 同理同学们可以自行分析下i和i的字节码指令执行的步骤。 i的字节码指令如下其中iinc 1 by 1指令指的是将局部变量表1号位置增加1其实就实现了i的操作。 而i只是对两个字节码指令的顺序进行了更改 面试题 问int i 0; i i; 最终i的值是多少 答答案是0我通过分析字节码指令发现i先把0取出来放入临时的操作数栈中 接下来对i进行加1i变成了1最后再将之前保存的临时值0放入i最后i就变成了0。 属性 属性主要指的是类的属性比如源码的文件名、内部类的列表等。 2.2.3 玩转字节码常用工具 javap javap是JDK自带的反编译工具可以通过控制台查看字节码文件的内容。适合在服务器上查看字节码文件内容。 直接输入javap查看所有参数。输入javap -v 字节码文件名称 查看具体的字节码信息。如果jar包需要先使用 jar –xvf 命令解压。 jclasslib插件 jclasslib也有Idea插件版本建议开发时使用Idea插件版本可以在代码编译之后实时看到字节码文件内容。 安装方式 1、打开idea的插件页面搜索jclasslib 2、选中要查看的源代码文件选择 视图(View) - Show Bytecode With Jclasslib 右侧会展示对应源代码编译后的字节码文件内容 tips: 1、一定要选择文件再点击视图(view)菜单否则菜单项不会出现。 2、文件修改后一定要重新编译之后再点击刷新按钮。 Arthas Arthas 是一款线上监控诊断产品通过全局视角实时查看应用 load、内存、gc、线程的状态信息并能在不修改应用代码的情况下对业务问题进行诊断大大提升线上问题排查效率。 官网https://arthas.aliyun.com/doc/ Arthas的功能列表如下 安装方法 1、将 资料/工具/arthas-boot.jar 文件复制到任意工作目录。 2、使用java -jar arthas-boot.jar 启动程序。 3、输入需要Arthas监控的进程id。 4、输入命令即可使用。 dump 命令详解https://arthas.aliyun.com/doc/dump.html dump命令可以将字节码文件保存到本地如下将java.lang.String 的字节码文件保存到了/tmp/output目录下 $ dump -d /tmp/output java.lang.StringHASHCODE CLASSLOADER LOCATIONnull /tmp/output/java/lang/String.class Affect(row-cnt:1) cost in 138 ms.jad 命令详解https://arthas.aliyun.com/doc/jad.html jad命令可以将类的字节码文件进行反编译成源代码用于确认服务器上的字节码文件是否是最新的如下将demo.MathGame的源代码进行了显示。 $ jad --source-only demo.MathGame /** Decompiled with CFR 0_132.*/ package demo;import java.io.PrintStream; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Random; import java.util.concurrent.TimeUnit;public class MathGame {private static Random random new Random();public int illegalArgumentCount 0; ...2.3 类的生命周期 类的生命周期描述了一个类加载、使用、卸载的整个过程。整体可以分为 加载连接其中又分为验证、准备、解析三个子阶段初始化使用卸载 2.3.1 加载阶段 1、加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息程序员可以使用Java代码拓展的不同的渠道。 从本地磁盘上获取文件运行时通过动态代理生成比如Spring框架Applet技术通过网络获取字节码文件 2、类加载器在加载完类之后Java虚拟机会将字节码中的信息保存到方法区中方法区中生成一个InstanceKlass对象保存类的所有信息里边还包含实现特定功能比如多态的信息。 4、Java虚拟机同时会在堆上生成与方法区中数据类似的java.lang.Class对象作用是在Java代码中去获取类的信息以及存储静态字段的数据JDK8及之后。 2.3.2 连接阶段 连接阶段分为三个子阶段: 验证验证内容是否满足《Java虚拟机规范》。准备给静态变量赋初值。解析将常量池中的符号引用替换成指向内存的直接引用。 验证 验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。主要包含如下四部分具体详见《Java虚拟机规范》 1、文件格式验证比如文件是否以0xCAFEBABE开头主次版本号是否满足当前Java虚拟机版本要求。 2、元信息验证例如类必须有父类super不能为空。 3、验证程序执行指令的语义比如方法内的指令执行中跳转到不正确的位置。 4、符号引用验证例如是否访问了其他类中private的方法等。 对版本号的验证在JDK8的源码中如下 编译文件的主版本号不能高于运行环境主版本号如果主版本号相等副版本号也不能超过。 准备 准备阶段为静态变量static分配内存并设置初值每一种基本数据类型和引用数据类型都有其初值。 数据类型初始值int0long0Lshort0char‘\u0000’byte0booleanfalsedouble0.0引用****数据类型null 如下代码 public class Student{public static int value 1;}在准备阶段会为value分配内存并赋初值为0在初始化阶段才会将值修改为1。 final修饰的基本数据类型的静态变量准备阶段直接会将代码中的值进行赋值。 如下例子中变量加上final进行修饰在准备阶段value值就直接变成1了因为final修饰的变量后续不会发生值的变更。 来看这个案例 public class HsdbDemo {public static final int i 2;public static void main(String[] args) throws IOException, InstantiationException, IllegalAccessException {HsdbDemo hsdbDemo new HsdbDemo();System.out.println(i);System.in.read();} }从字节码文件也可以看到编译器已经确定了该字段指向了常量池中的常量2 解析 解析阶段主要是将常量池中的符号引用替换为直接引用符号引用就是在字节码文件中使用编号来访问常量池中的内容。 直接引用不在使用编号而是使用内存中地址进行访问具体的数据。 2.3.3 初始化阶段 初始化阶段会执行字节码文件中clinitclass init 类的初始化方法的字节码指令包含了静态代码块中的代码并为静态变量赋值。 如下代码编译成字节码文件之后会生成三个方法 public class Demo1 {public static int value 1;static {value 2;}public static void main(String[] args) {} }init方法会在对象初始化时执行main方法主方法clinit方法类的初始化阶段执行 继续来看clinit方法中的字节码指令 1、iconst_1将常量1放入操作数栈。此时栈中只有1这个数。 2、putstatic指令会将操作数栈上的数弹出来并放入堆中静态变量的位置字节码指令中#2指向了常量池中的静态变量value在解析阶段会被替换成变量的地址。 3、后两步操作类似执行value2将堆上的value赋值为2。 如果将代码的位置互换 public class Demo1 {static {value 2;}public static int value 1;public static void main(String[] args) {} }字节码指令的位置也会发生变化 这样初始化结束之后最终value的值就变成了1而不是2。 以下几种方式会导致类的初始化 1.访问一个类的静态变量或者静态方法注意变量是final修饰的并且等号右边是常量不会触发初始化。 2.调用Class.forName(String className)。 3.new一个该类的对象时。 4.执行Main方法的当前类。 添加-XX:TraceClassLoading 参数可以打印出加载并初始化的类 面试题1 如下代码的输出结果是什么 public class Test1 {public static void main(String[] args) {System.out.println(A);new Test1();new Test1();}public Test1(){System.out.println(B);}{System.out.println(C);}static {System.out.println(D);} }分析步骤 1、执行main方法之前先执行clinit指令。 指令会输出D 2、执行main方法的字节码指令。 指令会输出A 3、创建两个对象会执行两次对象初始化的指令。 这里会输出CB源代码中输出C这行被放到了对象初始化的一开始来执行。 所以最后的结果应该是DACBCB clinit不会执行的几种情况 如下几种情况是不会进行初始化指令执行的 1.无静态代码块且无静态变量赋值语句。 2.有静态变量的声明但是没有赋值语句。 3.静态变量的定义使用final关键字这类变量会在准备阶段直接进行初始化。 面试题2 如下代码的输出结果是什么 public class Demo01 {public static void main(String[] args) {new B02();System.out.println(B02.a);} }class A02{static int a 0;static {a 1;} }class B02 extends A02{static {a 2;} }分析步骤 1、调用new创建对象需要初始化B02优先初始化父类。 2、执行A02的初始化代码将a赋值为1。 3、B02初始化将a赋值为2。 变化 将new B02();注释掉会怎么样 分析步骤 1、访问父类的静态变量只初始化父类。 2、执行A02的初始化代码将a赋值为1。 补充练习题 分析如下代码执行结果: public class Test2 {public static void main(String[] args) {Test2_A[] arr new Test2_A[10];} }class Test2_A {static {System.out.println(Test2 A的静态代码块运行);} }数组的创建不会导致数组中元素的类进行初始化。 public class Test4 {public static void main(String[] args) {System.out.println(Test4_A.a);} }class Test4_A {public static final int a Integer.valueOf(1);static {System.out.println(Test3 A的静态代码块运行);} }final修饰的变量如果赋值的内容需要执行指令才能得出结果会执行clinit方法进行初始化。 2.4 类加载器 2.4.1 什么是类加载器 类加载器ClassLoader是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术类加载器只参与加载过程中的字节码获取并加载到内存这一部分。 类加载器会通过二进制流的方式获取到字节码文件的内容接下来将获取到的数据交给Java虚拟机虚拟机会在方法区和堆上生成对应的对象保存字节码信息。 2.4.2 类加载器的分类 类加载器分为两类一类是Java代码中实现的一类是Java虚拟机底层源码实现的。 虚拟机底层实现源代码位于Java虚拟机的源码中实现语言与虚拟机底层语言一致比如Hotspot使用C。主要目的是保证Java程序运行中基础类被正确地加载比如java.lang.StringJava虚拟机需要确保其可靠性。JDK中默认提供或者自定义JDK中默认提供了多种处理不同渠道的类加载器程序员也可以自己根据需求定制使用Java语言。所有Java中实现的类加载器都需要继承ClassLoader这个抽象类。 类加载器的设计JDK8和8之后的版本差别较大首先来看JDK8及之前的版本这些版本中默认的类加载器有如下几种 类加载器的详细信息可以通过Arthas的classloader命令查看 classloader - 查看 classloader 的继承树urls类加载信息使用 classloader 去 getResource BootstrapClassLoader是启动类加载器numberOfInstances是类加载器的数量只有1个loadedCountTotal是加载类的数量1861个。ExtClassLoader是扩展类加载器AppClassLoader是应用程序类加载器 2.4.3 启动类加载器 启动类加载器Bootstrap ClassLoader是由Hotspot虚拟机提供的、使用C编写的类加载器。默认加载Java安装目录/jre/lib下的类文件比如rt.jartools.jarresources.jar等。 运行如下代码 /*** 启动程序类加载器案例*/ public class BootstrapClassLoaderDemo {public static void main(String[] args) throws IOException {ClassLoader classLoader String.class.getClassLoader();System.out.println(classLoader);System.in.read();} }这段代码通过String类获取到它的类加载器并且打印结果是null。这是因为启动类加载器在JDK8中是由C语言来编写的在Java代码中去获取既不适合也不安全所以才返回null 在Arthas中可以通过sc -d 类名的方式查看加载这个类的类加载器详细的信息比如 通过上图可以看到java.lang.String类的类加载器是空的Hash值也是null。 用户扩展基础jar包 如果用户想扩展一些比较基础的jar包让启动类加载器加载有两种途径 放入jre/lib下进行扩展。不推荐尽可能不要去更改JDK安装目录中的内容会出现即时放进去由于文件名不匹配的问题也不会正常地被加载。**使用参数进行扩展。**推荐使用-Xbootclasspath/a:jar包目录/jar包名 进行扩展参数中的/a代表新增。 如下图在IDEA配置中添加虚拟机参数就可以加载D:/jvm/jar/classloader-test.jar这个jar包了。 2.4.4 扩展类加载器 扩展类加载器和应用程序类加载器都是JDK中提供的、使用Java编写的类加载器。它们的源码都位于sun.misc.Launcher中是一个静态内部类。继承自URLClassLoader。具备通过目录或者指定jar包将字节码文件加载到内存中。 继承关系图如下 ClassLoader类定义了具体的行为模式简单来说就是先从本地或者网络获得字节码信息然后调用虚拟机底层的方法创建方法区和堆上的对象。这样的好处就是让子类只需要去实现如何获取字节码信息这部分代码。SecureClassLoader提供了证书机制提升了安全性。URLClassLoader提供了根据URL获取目录下或者指定jar包进行加载获取字节码的数据。扩展类加载器和应用程序类加载器继承自URLClassLoader获得了上述的三种能力。 扩展类加载器Extension Class Loader是JDK中提供的、使用Java编写的类加载器。默认加载Java安装目录/jre/lib/ext下的类文件。 如下代码会打印ScriptEnvironment类的类加载器。ScriptEnvironment是nashorn框架中用来运行javascript语言代码的环境类他位于nashorn.jar包中被扩展类加载器加载 /*** 扩展类加载器*/ public class ExtClassLoaderDemo {public static void main(String[] args) throws IOException {ClassLoader classLoader ScriptEnvironment.class.getClassLoader();System.out.println(classLoader);} }打印结果如下 通过扩展类加载器去加载用户jar包 放入/jre/lib/ext下进行扩展。不推荐尽可能不要去更改JDK安装目录中的内容。使用参数进行扩展使用参数进行扩展。推荐使用-Djava.ext.dirsjar包目录 进行扩展,这种方式会覆盖掉原始目录可以用;(windows):(macos/linux)追加上原始目录 如下图中 使用引号将整个地址包裹起来这样路径中即便是有空格也不需要额外处理。路径中要包含原来ext文件夹同时在最后加上扩展的路径。 2.4.5 应用程序加载器 应用程序类加载器会加载classpath下的类文件默认加载的是项目中的类以及通过maven引入的第三方jar包中的类。 如下案例中打印出Student和FileUtils的类加载器 /*** 应用程序类加载器案例*/ public class AppClassLoaderDemo {public static void main(String[] args) throws IOException, InterruptedException {//当前项目中创建的Student类Student student new Student();ClassLoader classLoader Student.class.getClassLoader();System.out.println(classLoader);//maven依赖中包含的类ClassLoader classLoader1 FileUtils.class.getClassLoader();System.out.println(classLoader1);Thread.sleep(1000);System.in.read();} }输出结果如下 这两个类均由应用程序类加载器加载。 类加载器的加载路径可以通过classloader –c hash值 查看 2.5 双亲委派机制 双亲委派机制指的是当一个类加载器接收到加载类的任务时会自底向上查找是否加载过 再由顶向下进行加载。 详细流程 每个类加载器都有一个父类加载器。父类加载器的关系如下启动类加载器没有父类加载器 在类加载的过程中每个类加载器都会先检查是否已经加载了该类如果已经加载则直接返回否则会将加载请求委派给父类加载器。 案例1 比如com.itheima.my.A假设在启动类加载器的加载目录中而应用程序类加载器接到了加载类的任务。 1、应用程序类加载器首先判断自己加载过没有没有加载过就交给父类加载器 - 扩展类加载器。 2、扩展类加载器也没加载过交给他的父类加载器 - 启动类加载器。 3、启动类加载器发现已经加载过直接返回。 案例2 B类在扩展类加载器加载路径中同样应用程序类加载器接到了加载任务按照案例1中的方式一层一层向上查找发现都没有加载过。那么启动类加载器会首先尝试加载。它发现这类不在它的加载目录中向下传递给扩展类加载器。 扩展类加载器发现这个类在它加载路径中加载成功并返回。 如果第二次再接收到加载任务同样地向上查找。扩展类加载器发现已经加载过就可以返回了。 2.5.1 双亲委派机制的作用 1.保证类加载的安全性。通过双亲委派机制避免恶意代码替换JDK中的核心类库比如java.lang.String确保核心类库的完整性和安全性。 2.避免重复加载。双亲委派机制可以避免同一个类被多次加载。 2.5.2 如何指定加载类的类加载器 在Java中如何使用代码的方式去主动加载一个类呢 方式1使用Class.forName方法使用当前类的类加载器去加载指定的类。 方式2获取到类加载器通过类加载器的loadClass方法指定某个类加载器加载。 例如 2.5.3 三个面试题 1、如果一个类重复出现在三个类加载器的加载位置应该由谁来加载 启动类加载器加载根据双亲委派机制它的优先级是最高的 2、String类能覆盖吗在自己的项目中去创建一个java.lang.String类会被加载吗 不能会返回启动类加载器加载在rt.jar包中的String类。 3、类的双亲委派机制是什么 当一个类加载器去加载某个类的时候会自底向上查找是否加载过如果加载过就直接返回如果一直到最顶层的类加载器都没有加载再由顶向下进行加载。应用程序类加载器的父类加载器是扩展类加载器扩展类加载器的父类加载器是启动类加载器。双亲委派机制的好处有两点第一是避免恶意代码替换JDK中的核心类库比如java.lang.String确保核心类库的完整性和安全性。第二是避免一个类重复地被加载。 2.6 打破双亲委派机制 打破双亲委派机制历史上有三种方式但本质上只有第一种算是真正的打破了双亲委派机制 自定义类加载器并且重写loadClass方法。Tomcat通过这种方式实现应用之间类隔离《面试篇》中分享它的做法。线程上下文类加载器。利用上下文类加载器加载类比如JDBC和JNDI等。Osgi框架的类加载器。历史上Osgi框架实现了一套新的类加载器机制允许同级之间委托进行类的加载目前很少使用。 2.6.1 自定义类加载器 一个Tomcat程序中是可以运行多个Web应用的如果这两个应用中出现了相同限定名的类比如Servlet类Tomcat要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制当应用类加载器加载Web应用1中的MyServlet之后Web应用2中相同限定名的MyServlet类就无法被加载了。 Tomcat使用了自定义类加载器来实现应用之间类的隔离。 每一个应用会有一个独立的类加载器加载对应的类。 那么自定义加载器是如何能做到的呢首先我们需要先了解双亲委派机制的代码到底在哪里接下来只需要把这段代码消除即可。 ClassLoader中包含了4个核心方法双亲委派机制的核心代码就位于loadClass方法中。 public Class? loadClass(String name) 类加载的入口提供了双亲委派机制。内部会调用findClass 重要protected Class? findClass(String name) 由类加载器子类实现,获取二进制数据调用defineClass 比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。重要protected final Class? defineClass(String name, byte[] b, int off, int len) 做一些类名的校验然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中protected final void resolveClass(Class? c) 执行类生命周期中的连接阶段1、入口方法 2、再进入看下 如果查找都失败进入加载阶段首先会由启动类加载器加载这段代码在findBootstrapClassOrNull中。如果失败会抛出异常接下来执行下面这段代码 父类加载器加载失败就会抛出异常回到子类加载器的这段代码这样就实现了加载并向下传递。 3、最后根据传入的参数判断是否进入连接阶段 接下来实现打破双亲委派机制 package classloader.broken;//package com.itheima.jvm.chapter02.classloader.broken;import org.apache.commons.io.IOUtils;import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.ProtectionDomain; import java.util.regex.Matcher;/*** 打破双亲委派机制 - 自定义类加载器*/public class BreakClassLoader1 extends ClassLoader {private String basePath;private final static String FILE_EXT .class;//设置加载目录public void setBasePath(String basePath) {this.basePath basePath;}//使用commons io 从指定目录下加载文件private byte[] loadClassData(String name) {try {String tempName name.replaceAll(\\., Matcher.quoteReplacement(File.separator));FileInputStream fis new FileInputStream(basePath tempName FILE_EXT);try {return IOUtils.toByteArray(fis);} finally {IOUtils.closeQuietly(fis);}} catch (Exception e) {System.out.println(自定义类加载器加载失败错误原因 e.getMessage());return null;}}//重写loadClass方法Overridepublic Class? loadClass(String name) throws ClassNotFoundException {//如果是java包下还是走双亲委派机制if(name.startsWith(java.)){return super.loadClass(name);}//从磁盘中指定目录下加载byte[] data loadClassData(name);//调用虚拟机底层方法方法区和堆区创建对象return defineClass(name, data, 0, data.length);}public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {//第一个自定义类加载器对象BreakClassLoader1 classLoader1 new BreakClassLoader1();classLoader1.setBasePath(D:\\lib\\);Class? clazz1 classLoader1.loadClass(com.itheima.my.A);//第二个自定义类加载器对象BreakClassLoader1 classLoader2 new BreakClassLoader1();classLoader2.setBasePath(D:\\lib\\);Class? clazz2 classLoader2.loadClass(com.itheima.my.A);System.out.println(clazz1 clazz2);Thread.currentThread().setContextClassLoader(classLoader1);System.out.println(Thread.currentThread().getContextClassLoader());System.in.read();} }自定义类加载器父类怎么是AppClassLoader呢 默认情况下自定义类加载器的父类加载器是应用程序类加载器 以Jdk8为例ClassLoader类中提供了构造方法设置parent的内容 这个构造方法由另外一个构造方法调用其中父类加载器由getSystemClassLoader方法设置该方法返回的是AppClassLoader。 两个自定义类加载器加载相同限定名的类不会冲突吗 不会冲突在同一个Java虚拟机中只有相同类加载器相同的类限定名才会被认为是同一个类。 在Arthas中使用sc –d 类名的方式查看具体的情况。 如下代码 public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {//第一个自定义类加载器对象BreakClassLoader1 classLoader1 new BreakClassLoader1();classLoader1.setBasePath(D:\\lib\\);Class? clazz1 classLoader1.loadClass(com.itheima.my.A);//第二个自定义类加载器对象BreakClassLoader1 classLoader2 new BreakClassLoader1();classLoader2.setBasePath(D:\\lib\\);Class? clazz2 classLoader2.loadClass(com.itheima.my.A);System.out.println(clazz1 clazz2);}打印的应该是false因为两个类加载器不同尽管加载的是同一个类名最终Class对象也不是相同的。 通过Arthas看 也会出现两个不同的A类。 2.6.2 线程上下文类加载器 利用上下文类加载器加载类比如JDBC和JNDI等。 我们来看下JDBC的案例 1、JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动比如mysql驱动、oracle驱动。 package classloader.broken;//package com.itheima.jvm.chapter02.classloader.broken;import com.mysql.cj.jdbc.Driver;import java.sql.*;/*** 打破双亲委派机制 - JDBC案例*/public class JDBCExample {// JDBC driver name and database URLstatic final String JDBC_DRIVER com.mysql.cj.jdbc.Driver;static final String DB_URL jdbc:mysql:///bank1;// Database credentialsstatic final String USER root;static final String PASS 123456;public static void main(String[] args) {Connection conn null;Statement stmt null;try {conn DriverManager.getConnection(DB_URL, USER, PASS);stmt conn.createStatement();String sql;sql SELECT id, account_name FROM account_info;ResultSet rs stmt.executeQuery(sql);//STEP 4: Extract data from result setwhile (rs.next()) {//Retrieve by column nameint id rs.getInt(id);String name rs.getString(account_name);//Display valuesSystem.out.print(ID: id);System.out.print(, Name: name \n);}//STEP 5: Clean-up environmentrs.close();stmt.close();conn.close();} catch (SQLException se) {//Handle errors for JDBCse.printStackTrace();} catch (Exception e) {//Handle errors for Class.forNamee.printStackTrace();} finally {//finally block used to close resourcestry {if (stmt ! null)stmt.close();} catch (SQLException se2) {}// nothing we can dotry {if (conn ! null)conn.close();} catch (SQLException se) {se.printStackTrace();}//end finally try}//end try}//end main }//end FirstExample2、DriverManager类位于rt.jar包中由启动类加载器加载。 3、依赖中的mysql驱动对应的类由应用程序类加载器来加载。 在类中有初始化代码 DriverManager属于rt.jar是启动类加载器加载的。而用户jar包中的驱动需要由应用类加载器加载这就违反了双亲委派机制。这点存疑一会儿再讨论 那么问题来了DriverManager怎么知道jar包中要加载的驱动在哪儿 1、在类的初始化代码中有这么一个方法LoadInitialDrivers 2、这里使用了SPI机制去加载所有jar包中实现了Driver接口的实现类。 3、SPI机制就是在这个位置下存放了一个文件文件名是接口名文件里包含了实现类的类名。这样SPI机制就可以找到实现类了。 4、SPI中利用了线程上下文类加载器应用程序类加载器去加载类并创建对象。 总结 JDBC案例中真的打破了双亲委派机制吗 最早这个论点提出是在周志明《深入理解Java虚拟机》中他认为打破了双亲委派机制这种由启动类加载器加载的类委派应用程序类加载器去加载类的方式所以打破了双亲委派机制。 但是如果我们分别从DriverManager以及驱动类的加载流程上分析JDBC只是在DriverManager加载完之后通过初始化阶段触发了驱动类的加载类的加载依然遵循双亲委派机制。 所以我认为这里没有打破双亲委派机制只是用一种巧妙的方法让启动类加载器加载的类去引发的其他类的加载。 2.6.3 Osgi框架的类加载器 历史上OSGi模块化框架。它存在同级之间的类加载器的委托加载。OSGi还使用类加载器实现了热部署的功能。热部署指的是在服务不停止的情况下动态地更新字节码文件到内存中。 由于这种机制使用已经不多所以不再过多讨论OSGi着重来看下热部署在实际项目中的应用。 案例使用阿里arthas不停机解决线上问题 背景 小李的团队将代码上线之后发现存在一个小bug但是用户急着使用如果重新打包再发布需要一个多小时的时间所以希望能使用arthas尽快的将这个问题修复。 思路 在出问题的服务器上部署一个 arthas并启动。jad --source-only 类全限定名 目录/文件名.java jad 命令反编译然后可以用其它编译器比如 vim 来修改源码mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录 ​ mc 命令用来编译修改过的代码 retransform class文件所在目录/xxx.class ​ 用 retransform 命令加载新的字节码 详细流程 1、这段代码编写有误在枚举中的类型判断上使用了 而不是equals。 2、枚举中是这样定义的1001是普通用户1002是VIP用户 3、由于代码有误导致传递1001参数时返回的是收费用户的内容。 4、jad --source-only 类全限定名 目录/文件名.java 使用 jad 命令反编译然后可以用其它编译器比如 vim 来修改源码 这里直接双击文件使用finalShell编辑 5、mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录 使用mc 命令用来编译修改过的代码 6、retransform class文件所在目录/xxx.class 用 retransform 命令加载新的字节码 7、测试 注意事项 1、程序重启之后字节码文件会恢复除非将class文件放入jar包中进行更新。 2、使用retransform不能添加方法或者字段也不能更新正在执行中的方法。 由于JDK9引入了module的概念类加载器在设计上发生了很多变化。 1.启动类加载器使用Java编写位于jdk.internal.loader.ClassLoaders类中。 Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。 启动类加载器依然无法通过java代码获取到返回的仍然是null保持了统一。 2、扩展类加载器被替换成了平台类加载器Platform Class Loader。 ​ 平台类加载器遵循模块化方式加载字节码文件所以继承关系从URLClassLoader变成了BuiltinClassLoaderBuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容自身没有特殊的逻辑。 3 运行时数据区 Java虚拟机在运行Java程序过程中管理的内存区域称之为运行时数据区。《Java虚拟机规范》中规定了每一部分的作用。 3.1 程序计数器 程序计数器Program Counter Register也叫PC寄存器每个线程会通过程序计数器记录当前要执行的的字节码指令的地址。 一个程序计数器的具体案例 在加载阶段虚拟机将字节码文件中的指令读取到内存之后会将原文件中的偏移量转换成内存地址。每一条字节码指令都会拥有一个内存地址。 在代码执行过程中程序计数器会记录下一行字节码指令的地址。执行完当前指令之后虚拟机的执行引擎根据程序计数器执行下一行指令。这里为了简单起见使用偏移量代替真实内存中执行时保存的应该是地址。 比如当前执行的是偏移量为0的指令那么程序计数器中保存的就是下一条的地址偏移量1。 一路向下执行 一直执行到方法的最后一行指令此时方法执行return语句当前方法执行结束程序计数器中会放入方法出口的地址栈中讲解简单来说就是这个B方法结束了A调用了B那么要回到A方法 所以程序计数器可以控制程序指令的进行实现分支、跳转、异常等逻辑。不管是分支、跳转、异常只需要在程序计数器中放入下一行要执行的指令地址即可。 在多线程执行情况下Java虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行。 程序计数器会出现内存溢出吗 内存溢出指的是程序在使用某一块内存区域时存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。由于每个线程只存储一个固定长度的内存地址程序计数器是不会发生内存溢出的。程序员无需对程序计数器做任何处理。 3.2 Java虚拟机栈 Java虚拟机栈Java Virtual Machine Stack采用栈的数据结构来管理方法调用中的基本数据先进后出First In Last Out,每一个方法的调用使用一个栈帧Stack Frame来保存。 public class MethodDemo { public static void main(String[] args) { study(); }public static void study(){eat();sleep();} public static void eat(){ System.out.println(吃饭); } public static void sleep(){ System.out.println(睡觉); }}main方法执行时会创建main方法的栈帧 接下来执行study方法会创建study方法的栈帧 进入eat方法创建eat方法的栈帧 eat方法执行完之后会弹出它的栈帧 然后调用sleep方法创建sleep方法栈帧 最后study方法结束之后弹出栈帧main方法结束之后弹出main的栈帧。 在IDEA中也可以看到对应的栈帧 package chapter03.frame;/*** 栈帧测试1*/ public class FrameDemo {public static void main(String[] args) {A();}public static void A() {System.out.println(A执行了...);B();}public static void B() {System.out.println(B执行了...);C();}public static void C() {System.out.println(C执行了...);throw new RuntimeException(测试);} }打上断点debug之后会出现栈帧内容 Java虚拟机栈随着线程的创建而创建而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行每个线程都会包含一个自己的虚拟机栈。如下就有两个线程的虚拟机栈main线程和线程A。 Java虚拟机栈的栈帧中主要包含三方面的内容 局部变量表局部变量表的作用是在运行过程中存放所有的局部变量操作数栈操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域帧数据帧数据主要包含动态链接、方法出口、异常表的引用 3.2.1 局部变量表 局部变量表的作用是在方法执行过程中存放所有的局部变量。局部变量表分为两种一种是字节码文件中的另外一种是栈帧中的也就是保存在内存中。栈帧中的局部变量表是根据字节码文件中的内容生成的。 我们先来看下字节码文件中的局部变量表编译成字节码文件时就可以确定局部变量表的内容。 public static void test1(){int i 0;long j 1; }test1方法的局部变量表如下 局部变量表中保存了字节码指令生效的偏移量 比如i这个变量它的起始PC是2代表从lconst_1这句指令开始才能使用i长度为3也就是2-4这三句指令都可以使用i。为什么从2才能使用因为0和1这两句字节码指令还在处理int i 0这句赋值语句。j这个变量只有等3指令执行完之后也就是long j 1代码执行完之后才能使用所以起始PC为4只能在4这行字节码指令中使用。 接下来看下栈帧中的局部变量表栈帧中的局部变量表是一个数组数组中每一个位置称之为槽(slot) long和double类型占用两个槽其他类型占用一个槽。 i占用数组下标为0的位置j占用数组下标1-2的位置。 刚才看到的是静态方法实例方法中的序号为0的位置存放的是this指的是当前调用方法的对象运行时会在内存中存放实例对象的地址。 方法参数也会保存在局部变量表中其顺序与方法中参数定义的顺序一致。局部变量表保存的内容有实例方法的this对象方法的参数方法体中声明的局部变量。 test3方法中包含两个参数k,m这两个参数也会被加入到局部变量表中。 以下代码的局部变量表中会占用几个槽 public void test4(int k,int m){{int a 1;int b 2;}{int c 1;}int i 0;long j 1; }为了节省空间局部变量表中的槽是可以复用的一旦某个局部变量不再生效当前槽就可以再次被使用。 1、方法执行时实例对象this、k、m 会被放入局部变量表中占用3个槽 2、将1的值放入局部变量表下标为3的位置上相当于给a进行赋值。 3、将2放入局部变量表下标为4的位置给b赋值为2。 4、ab已经脱离了生效范围所以下标为3和4的这两个位置可以复用。此时c的值1就可以放入下标为3的位置。 4、脱离c的生效范围之后给i赋值就可以复用c的位置。 5、最后放入jj是一个long类型占用两个槽。但是可以复用b所在的位置所以占用4和5这两个位置 所以局部变量表数值的长度为6。这一点在编译期间就可以确定了运行过程中只需要在栈帧中创建长度为6的数组即可。 3.2.2 操作数栈 操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。他是一种栈式的数据结构如果一条指令将一个值压入操作数栈则后面的指令可以弹出并使用该值。 在编译期就可以确定操作数栈的最大深度从而在执行时正确的分配内存大小。 比如之前的相加案例中操作数栈最大的深入会出现在这个时刻 所以操作数栈的深度会定义为2。 3.2.3 帧数据 帧数据主要包含动态链接、方法出口、异常表的引用。 动态链接 当前类的字节码指令引用了其他类的属性或者方法时需要将符号引用编号转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。 方法出口 方法出口指的是方法在正确或者异常结束时当前栈帧会被弹出同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中需要存储此方法出口的地址。 异常表 异常表存放的是代码中异常的处理信息包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。 如下案例i1这行源代码编译成字节码指令之后会包含偏移量2-4这三行指令。其中2-3是对i进行赋值1的操作4的没有异常就跳转到10方法结束。如果出现异常的情况下继续执行到7这行指令7会将异常对象放入操作数栈中这样在catch代码块中就可以使用异常对象了。接下来执行8-9对i进行赋值为2的操作。 所以异常表中异常捕获的起始偏移量就是2结束偏移量是4在2-4执行过程中抛出了java.lang.Exception对象或者子类对象就会将其捕获然后跳转到偏移量为7的指令。 栈内存溢出 Java虚拟机栈如果栈帧过多占用内存超过栈内存可以分配的最大大小就会出现内存溢出。Java虚拟机栈内存溢出时会出现StackOverflowError的错误。 如果我们不指定栈的大小JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。 我们来模拟下栈内存的溢出情况: public static int count 0;//递归方法调用自己public static void recursion(){System.out.println(count);recursion();}使用递归让方法调用自身但是不设置退出条件。定义调用次数的变量每一次调用让变量加1。查看错误发生时总调用的次数。 执行之后可以打印出溢出时总栈帧的数量并且发现虚拟机已经抛出了StackOverflow的错误。 要修改Java虚拟机栈的大小可以使用虚拟机参数 -Xss 。 语法-Xss栈大小单位字节默认必须是 1024 的倍数、k或者K(KB)、m或者M(MB)、g或者G(GB) 例如 -Xss1048576 -Xss1024K -Xss1m -Xss1g操作步骤如下不同IDEA版本的设置方式会略有不同 1、点击修改配置Modify options 2、点击Add VM options 3、添加参数 调成512k之后明显发现最大栈帧数量减少了 注意事项 1、与-Xss类似也可以使用 -XX:ThreadStackSize 调整标志来配置堆栈大小。 格式为 -XX:ThreadStackSize1024 2、HotSpot JVM对栈大小的最大值和最小值有要求 ​ 比如测试如下两个参数会直接报错: -Xss1k -Xss1025mWindows64位下的JDK8测试最小值为180k最大值为1024m。 3、局部变量过多、操作数栈深度过大也会影响栈内存的大小。我们在这段代码中添加一些局部变量。 //递归方法调用自己 public static void recursion() {long a,b,c,d,f,g,h,i,j,k;System.out.println(count);recursion(); }使用默认大小来测试之后发现栈帧数量从10000减少了到8000 一般情况下工作中即便使用了递归进行操作栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为-Xss256k节省内存。 3.3 本地方法栈 Java虚拟机栈存储了Java方法调用时的栈帧而本地方法栈存储的是native本地方法的栈帧。 在Hotspot虚拟机中Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。 比如测试下这段代码 /*** 本地方法栈*/ public class NativeDemo1 {public static void main(String[] args) {try {FileOutputStream fileOutputStream new FileOutputStream(E:\\123.txt);fileOutputStream.write(1);} catch (FileNotFoundException e) {e.printStackTrace();} catch (IOException e) {e.printStackTrace();}} }执行之后发生异常会打印出所有栈帧的名字 open0是一个本地方法所以创建了本地方法的栈帧。本地方法和Java虚拟机方法的栈帧在一个栈上。 3.4 堆内存 一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。栈上的局部变量表中可以存放堆上对象的引用。静态变量也可以存放堆对象的引用通过静态变量就可以实现对象在线程之间共享。 public class Test { public static void main(String[] args) { Student s1 new Student(); s1.name 张三; s1.age 18; s1.id 1;s1.printTotalScore(); s1.printAverageScore(); Student s2 new Student(); s2.name 李四; s2.age 19; s2.id 2; s2.printTotalScore(); s2.printAverageScore(); } }这段代码中通过new关键字创建了两个Student类的对象这两个对象会被存放在堆上。在栈上通过s1和s2两个局部变量保存堆上两个对象的地址从而实现了引用关系的建立。 堆内存的溢出 通过new关键字不停创建对象放入集合中模拟堆内存的溢出观察堆溢出之后的异常信息。 package chapter03.heap;import java.io.IOException; import java.util.ArrayList;/*** 堆内存的使用和回收*/ public class Demo1 {public static void main(String[] args) throws InterruptedException, IOException {ArrayListObject objects new ArrayListObject();System.in.read();while (true){objects.add(new byte[1024 * 1024 * 100]);Thread.sleep(1000);}} }堆内存大小是有上限的当对象一直向堆中放入对象达到上限之后就会抛出OutOfMemory错误。在这段代码中不停创建100M大小的字节数组并放入ArrayList集合中最终超过了堆内存的上限。抛出如下错误 三个重要的值 堆空间有三个需要关注的值used、total、max。used指的是当前已使用的堆内存total是java虚拟机已经分配的可用堆内存max是java虚拟机可以分配的最大堆内存。 堆内存used total max三个值可以通过dashboard命令看到。 手动指定刷新频率不指定默认5秒一次dashboard –i 刷新频率(毫秒) 随着堆中的对象增多当total可以使用的内存即将不足时java虚拟机会继续分配内存给堆。 此时used达到了total的大小Java虚拟机会向操作系统申请更大的内存。 但是这个申请过程不是无限的total最多只能与max相等。 那么是不是当used max total的时候堆内存就溢出了呢 不是堆内存溢出的判断条件比较复杂在下一章《垃圾回收器》中会详细介绍。 如果不设置任何的虚拟机参数max默认是系统内存的1/4total默认是系统内存的1/64。在实际应用中一般都需要设置total和max的值。 Oracle官方文档https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html 设置堆的大小 要修改堆的大小可以使用虚拟机参数 –Xmxmax最大值和-Xms (初始的total)。 语法-Xmx值 -Xms值 单位字节默认必须是 1024 的倍数、k或者K(KB)、m或者M(MB)、g或者G(GB) 限制Xmx必须大于 2 MBXms必须大于1MB -Xms6291456 -Xms6144k -Xms6m -Xmx83886080 -Xmx81920k -Xmx80m这样可以将max和初始的total都设置为4g在启动后就已经获得了最大的堆内存大小。运行过程中不需要向操作系统申请。 使用arthas的memory命令同样可以看到 为什么arthas中显示的heap堆大小与设置的值不一样呢 arthas中的heap堆内存使用了JMX技术中内存获取方式这种方式与垃圾回收器有关计算的是可以分配对象的内存而不是整个内存。 建议 Java服务端程序开发时建议将-Xmx和-Xms设置为相同的值这样在程序启动之后可使用的总内存就是最大内存而无需向java虚拟机再次申请减少了申请并分配内存时间上的开销同时也不会出现内存过剩之后堆收缩的情况。-Xmx具体设置的值与实际的应用程序运行环境有关在《实战篇》中会给出设置方案。 3.5 方法区 方法区是存放基础信息的位置线程共享主要包含三部分内容 类的元信息保存了所有类的基本信息运行时常量池保存了字节码文件中的常量池内容字符串常量池保存了字符串常量 3.5.1 类的元信息 方法区是用来存储每个类的基本信息元信息一般称之为InstanceKlass对象。在类的加载阶段完成。其中就包含了类的字段、方法等字节码文件中的内容同时还保存了运行过程中需要使用的虚方法表实现多态的基础等信息。 3.5.2 运行时常量池 方法区除了存储类的元信息之外还存放了运行时常量池。常量池中存放的是字节码中的常量池内容。 字节码文件中通过编号查表的方式找到常量这种常量池称为静态常量池。当常量池加载到内存中之后可以通过内存地址快速的定位到常量池中的内容这种常量池称为运行时常量池。 3.5.3 方法区的实现 方法区是《Java虚拟机规范》中设计的虚拟概念每款Java虚拟机在实现上都各不相同。Hotspot设计如下 JDK7及之前的版本将方法区存放在堆区域中的永久代空间堆的大小由虚拟机参数来控制。 JDK8及之后的版本将方法区存放在元空间中元空间位于操作系统维护的直接内存中默认情况下只要不超过操作系统承受的上限可以一直分配。 可以通过arthas的memory命令看到方法区的名称以及大小 JDK7及之前的版本查看ps_perm_gen属性。 JDK8及之后的版本查看metaspace属性。 3.5.4 方法区的溢出 通过ByteBuddy框架动态创建类并将字节码数据加载到内存中。通过死循环不停地加载到方法区观察方法区是否会出现内存溢出的情况。分别在JDK7和JDK8上运行上述代码。 ByteBuddy是一个基于Java的开源库用于生成和操作Java字节码。 1.引入依赖 dependencygroupIdnet.bytebuddy/groupIdartifactIdbyte-buddy/artifactIdversion1.12.23/version/dependency2.创建ClassWriter对象 ClassWriter classWriter new ClassWriter(0);3.调用visit方法创建字节码数据。 classWriter.visit(Opcodes.V1_7,Opcodes.ACC_PUBLIC,name,null ,java/lang/Object,null); byte[] bytes classWriter.toByteArray();代码 package chapter03.methodarea;import net.bytebuddy.jar.asm.ClassWriter; import net.bytebuddy.jar.asm.Opcodes;import java.io.IOException;/*** 方法区的溢出测试*/ public class Demo1 extends ClassLoader {public static void main(String[] args) throws IOException {System.in.read();Demo1 demo1 new Demo1();int count 0;while (true) {String name Class count;ClassWriter classWriter new ClassWriter(0);classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null, java/lang/Object, null);byte[] bytes classWriter.toByteArray();demo1.defineClass(name, bytes, 0, bytes.length);System.out.println(count);}} }实验发现JDK7上运行大概十几万次就出现了错误。 在JDK8上运行百万次程序都没有出现任何错误但是内存会直线升高。这说明JDK7和JDK8在方法区的存放上采用了不同的设计。 JDK7将方法区存放在堆区域中的永久代空间堆的大小由虚拟机参数-XX:MaxPermSize值来控制。JDK8将方法区存放在元空间中元空间位于操作系统维护的直接内存中默认情况下只要不超过操作系统承受的上限可以一直分配。可以使用-XX:MaxMetaspaceSize值将元空间最大大小进行限制。 在JDK8中将最大元空间内存设置为256m再次测试 这次就出现了MetaSpace溢出的错误 3.5.5 字符串常量池 方法区中除了类的元信息、运行时常量池之外还有一块区域叫字符串常量池(StringTable)。 字符串常量池存储在代码中定义的常量字符串内容。比如“123” 这个123就会被放入字符串常量池。 如下代码执行时代码中包含abc字符串就会被直接放入字符串常量池。在堆上创建String对象并通过局部变量s1引用堆上的对象。 接下来通过s2局部变量引用字符串常量池的abc。 所以s1和s2指向的不是同一个对象打印出false。 字符串常量池和运行时常量池有什么关系 早期设计时字符串常量池是属于运行时常量池的一部分他们存储的位置也是一致的。后续做出了调整将字符串常量池和运行时常量池做了拆分。 StringTable的练习题1 /*** 字符串常量池案例*/ public class Demo2 {public static void main(String[] args) {String a 1;String b 2;String c 12;String d a b;System.out.println(c d);} }1、首先将1放入字符串常量池通过局部变量a引用字符串常量池中的1字符串。 2、同理处理b和c 3、将a和b指向的字符串进行连接本质上就是使用StringBuilder进行连接最后创建了一个新的字符串放入堆中。然后将局部变量d指向堆上的对象。 4、所以c和d指向的不是同一个对象打印出的结果就是false。 StringTable的练习题2 package chapter03.stringtable;/*** 字符串常量池案例*/ public class Demo3 {public static void main(String[] args) {String a 1;String b 2;String c 12;String d 1 2;System.out.println(c d);} }编译之后的字节码指令如下 说明在编译阶段已经将1和2进行连接最终生成12的字符串常量池中的结果。所以返回结果就是truec和d都指向字符串常量池中的对象。 总结一下 神奇的intern String.intern()方法是可以手动将字符串放入字符串常量池中分别在JDK6 JDK8下执行代码JDK6 中结果是false false JDK8中是true false package chapter03.stringtable;/*** intern案例*/ public class Demo4 {public static void main(String[] args) {String s1 new StringBuilder().append(think).append(123).toString();System.out.println(s1.intern() s1); // System.out.println(s1.intern() s1.intern());String s2 new StringBuilder().append(ja).append(va).toString();System.out.println(s2.intern() s2);} }先来分析JDK6中代码执行步骤如下 1、使用StringBuilder的将think和123拼接成think123转换成字符串在堆上创建一个字符串对象。局部变量s1指向堆上的对象。 2、调用s1.intern方法会在字符串常量池中创建think123的对象最后将对象引用返回。所以s1.intern和s1指向的不是同一个对象。打印出false。 3、同理通过StringBuilder在堆上创建java字符串对象。这里注意字符串常量池中本来就有一个java字符串对象这是java虚拟机自身使用的所以启动时就会创建出来。 4、调用s2.intern发现字符串常量池中已经有java字符串对象了就将引用返回。所以s2.intern指向的是字符串常量池中的对象而s2指向的是堆中的对象。打印结果为false。 接下来分析JDK7中JDK7及之后版本中由于字符串常量池在堆上所以intern () 方法会把第一次遇到的字符串的引用放入字符串常量池。 代码执行步骤如下 1、执行第二句代码时由于字符串常量池中没有think123的字符串所以直接创建一个引用指向堆中的think123对象。所以s1.intern和s1指向的都是堆上的对象打印结果为true。 2、s2.intern方法调用时字符串常量池中已经有java字符串了所以将引用返回。这样打印出来的结果就是false。 后续JDK版本中如果Java虚拟机不需要使用java字符串那么字符串常量池中就不会存放java。打印结果有可能会出现两个true。 面试题静态变量存储在哪里呢 JDK6及之前的版本中静态变量是存放在方法区中的也就是永久代。JDK7及之后的版本中静态变量是存放在堆中的Class对象中脱离了永久代。具体源码可参考虚拟机源码BytecodeInterpreter针对putstatic指令的处理。 3.6 直接内存 直接内存Direct Memory并不在《Java虚拟机规范》中存在所以并不属于Java运行时的内存区域。 在 JDK 1.4 中引入了 NIO 机制使用了直接内存主要为了解决以下两个问题: 1、Java堆中的对象如果不再使用要回收回收时会影响对象的创建和使用。 2、IO操作比如读文件需要先把文件读入直接内存缓冲区再把数据复制到Java堆中。 现在直接放入直接内存即可同时Java堆上维护直接内存的引用减少了数据复制的开销。写文件也是类似的思路。 使用堆创建对象的过程 使用直接内存创建对象的过程不需要进行复制对象数据直接存放在直接内存中 使用方法 要创建直接内存上的数据可以使用ByteBuffer。 语法 ByteBuffer directBuffer ByteBuffer.allocateDirect(size); 注意事项 arthas的memory命令可以查看直接内存大小属性名direct。 代码 package chapter03.direct;import java.io.IOException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List;/*** 直接内存的使用和回收*/ public class Demo1 {public static int size 1024 * 1024 * 100; //100mbpublic static ListByteBuffer list new ArrayListByteBuffer();public static int count 0;public static void main(String[] args) throws IOException, InterruptedException {System.in.read();while (true) {//1.创建DirectByteBuffer对象并返回//2.在DirectByteBuffer构造方法中向操作系统申请直接内存空间ByteBuffer directBuffer ByteBuffer.allocateDirect(size);//directBuffer null;list.add(directBuffer);System.out.println(count);Thread.sleep(5000);}} }运行之后用arthas监控发现每隔5秒直接内存会增长100M 如果将Thread.sleep(5000);注释掉让直接内存快速大量分配。操作系统内存不足时就会报错 但是工作中服务器上有可能部署了其他应用为了避免将内存耗尽需要设置直接内存的最大值。如果需要手动调整直接内存的大小可以使用XX:MaxDirectMemorySize大小 单位k或K表示千字节m或M表示兆字节g或G表示千兆字节。默认不设置该参数情况下JVM 自动选择 最大分配的大小。 以下示例以不同的单位说明如何将 直接内存大小设置为 1024 KB -XX:MaxDirectMemorySize1m -XX:MaxDirectMemorySize1024k -XX:MaxDirectMemorySize1048576在Idea中设置直接内存最大值为1g 直接循环11次之后超过最大值就会报错 4 垃圾回收 在C/C这类没有自动垃圾回收机制的语言中一个对象如果不再使用需要手动释放否则就会出现内存泄漏。 内存泄漏指的是不再使用的对象在系统中未被回收内存泄漏的积累可能会导致内存溢出。 在这段代码中通过死循环不停创建Test类的对象每一轮循环结束之后这次创建的对象就不再使用了。但是没有手动调用删除对象的方法此时对象就会出现内存泄漏。 这段代码中手动调用delete删除对象就不会出现内存泄漏。 我们称这种释放对象的过程为垃圾回收而需要程序员编写代码进行回收的方式为手动回收。手动回收的方式相对来说回收比较及时删除代码执行之后对象就被回收了可以快速释放内存。缺点是对程序员要求比较高很容易出现创建完对象之后程序员忘记释放对象。 Java中为了简化对象的释放引入了自动的垃圾回收Garbage Collection简称GC机制。通过垃圾回收器来对不再使用的对象完成自动的回收垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器。 垃圾回收器如果发现某个对象不再使用就可以回收该对象。 自动垃圾回收自动根据对象是否使用由虚拟机来回收对象 优点降低程序员实现难度、降低对象回收bug的可能性缺点程序员无法控制内存回收的及时性 手动垃圾回收由程序员编程实现对象的删除 优点回收及时性高由程序员把控回收的时机缺点编写不当容易出现悬空指针、重复释放、内存泄漏等问题 那么垃圾回收器需要负责对哪些部分的内存进行回收呢 首先是线程不共享的部分都是伴随着线程的创建而创建线程的销毁而销毁。而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。所以这一部分不需要垃圾回收器负责回收。 4.1 方法区的回收 方法区中能回收的内容主要就是不再使用的类。 判定一个类可以被卸载。需要同时满足下面三个条件 1、此类所有实例对象都已经被回收在堆中不存在任何该类的实例对象以及子类对象。 这段代码中就将局部变量对堆上实例对象的引用去除了所以对象就可以被回收。 2、加载该类的类加载器已经被回收。 这段代码让局部变量对类加载器的引用去除类加载器就可以回收。 3、该类对应的 java.lang.Class 对象没有在任何地方被引用。 代码: package chapter04.gc;import java.net.URL; import java.net.URLClassLoader; import java.util.ArrayList;/*** 类的卸载*/ public class ClassUnload {public static void main(String[] args) throws InterruptedException {try {ArrayListClass? classes new ArrayList();ArrayListURLClassLoader loaders new ArrayList();ArrayListObject objs new ArrayList();while (true) {URLClassLoader loader new URLClassLoader(new URL[]{new URL(file:D:\\lib\\)});Class? clazz loader.loadClass(com.itheima.my.A);Object o clazz.newInstance();// objs.add(o); // classes.add(clazz); // loaders.add(loader);System.gc();}} catch (Exception e) {e.printStackTrace();}} }添加这两个虚拟机参数进行测试 -XX:TraceClassLoading -XX:TraceClassUnloading如果注释掉代码中三句add调用就可以同时满足3个条件。但是需要手动调用System.gc()方法让垃圾回收器进行回收。 如果需要手动触发垃圾回收可以调用System.gc()方法。 语法 System.gc() 注意事项 调用System.gc()方法并不一定会立即回收垃圾仅仅是向Java虚拟机发送一个垃圾回收的请求具体是否需要执行垃圾回收Java虚拟机会自行判断。 执行之后日志中就会打印出类卸载的内容 那么类卸载主要用在什么场景下呢 开发中此类场景一般很少出现主要在如 OSGi、JSP 的热部署等应用场景中。 每个jsp文件对应一个唯一的类加载器当一个jsp文件修改了就直接卸载这个jsp类加载器。重新创建类加载器重新加载jsp文件。 4.2 如何判断对象可以回收 垃圾回收器要回收对象的第一步就是判断哪些对象可以回收。Java中的对象是否能被回收是根据对象是否被引用来决定的。如果对象被引用了说明该对象还在使用不允许被回收。 比如下面代码的内存结构图 第一行代码执行之后堆上创建了Demo类的实例对象同时栈上保存局部变量引用堆上的对象。 第二行代码执行之后局部变量对堆上的对象引用去掉那么堆上的对象就可以被回收了。 一个更复杂的案例 这个案例中如果要让对象a和b回收必须将局部变量到堆上的引用去除。 那么问题来了A和B互相之间的引用需要去除吗答案是不需要因为局部变量都没引用这两个对象了在代码中已经无法访问这两个对象即便他们之间互相有引用关系也不影响对象的回收。 判断对象是否可以回收主要有两种方式引用计数法和可达性分析法。 4.2.1 引用计数法 引用计数法会为每个对象维护一个引用计数器当对象被引用时加1取消引用时减1。 比如下图中对象A的计数器初始为0局部变量a1对它引用之后计数器加1就变成了1。同样A对B产生了引用B的计数器也是1。 引用计数法的优点是实现简单C中的智能指针就采用了引用计数法但是它也存在缺点主要有两点 1.每次引用和取消引用都需要维护计数器对系统性能会有一定的影响 2.存在循环引用问题所谓循环引用就是当A引用BB同时引用A时会出现对象无法回收的问题。 这张图上由于A和B之间存在互相引用所以计数器都为1两个对象都不能被回收。但是由于没有局部变量对这两个代码产生引用代码中已经无法访问到这两个对象理应可以被回收。 我们来做一个实验验证下Java中循环引用不会导致内存泄漏因为Java虚拟机根本没有使用引用计数法。首先我们要学会去看一个对象有没有被回收可以通过垃圾回收日志来查看。 如果想要查看垃圾回收的信息可以使用-verbose:gc参数。 语法 -verbose:gc 加上这个参数之后执行代码发现对象确实被回收了 通过不同的死循环创建对象内存并没有上升一直维持在1000K,说明每轮循环创建的两个对象在垃圾回收之后都被回收了。 4.2.2 可达性分析法 Java使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类垃圾回收的根对象GC Root和普通对象对象与对象之间存在引用关系。 下图中A到B再到C和D形成了一个引用链可达性分析算法指的是如果从某个到GC Root对象是可达的对象就不可被回收。 哪些对象被称之为GC Root对象呢 线程Thread对象引用线程栈帧中的方法参数、局部变量等。系统类加载器加载的java.lang.Class对象引用类中的静态变量。 监视器对象用来保存同步锁synchronized关键字持有的对象。 本地方法调用时使用的全局对象。 通过arthas和eclipse Memory Analyzer (MAT) 工具可以查看GC RootMAT工具是eclipse推出的Java堆内存检测工具。具体操作步骤如下 1、使用arthas的heapdump命令将堆内存快照保存到本地磁盘中。 2、使用MAT工具打开堆内存快照文件。 3、选择GC Roots功能查看所有的GC Root。 步骤详解 1、代码如下 package com.itheima.jvm.chapter04;import java.io.IOException;public class ReferenceCounting {public static A a2 null;public static void main(String[] args) throws IOException { // while (true){A a1 new A();B b1 new B();a1.b b1;b1.a a1;a2 a1;System.in.read(); // a1 null; // b1 null; // }} }class A {B b; // byte[] t new byte[1024 * 1024 * 10]; }class B {A a; // byte[] t new byte[1024 * 1024 * 10]; }2、使用arthas连接到程序输入如下命令: heapdump 目录/test2.hprof这样就生成了一个堆内存快照后面介绍简单来说就是包含了所有堆中的对象信息。 3、打开资料中提供的MAT工具如果出现如下错误请将环境变量中的JDK版本升级到17以上。 4、选择菜单中的打开堆内存快照功能并选择刚才生成的文件。 5、选择内存泄漏检测报告并确定。 6、通过菜单找到GC Roots。 7、MAT对4类GC Root对象做了分类。 8、找到静态变量。 9、找到局部变量 4.3 常见的引用对象 可达性算法中描述的对象引用一般指的是强引用即是GCRoot对象对普通对象有引用关系只要这层关系存在普通对象就不会被回收。除了强引用之外Java中还设计了几种其他引用方式 软引用弱引用虚引用终结器引用 4.3.1 软引用 软引用相对于强引用是一种比较弱的引用关系如果一个对象只有软引用关联到它当程序内存不足时就会将软引用中的数据进行回收。在JDK 1.2版之后提供了SoftReference类来实现软引用软引用常用于缓存中。 如下图中对象A被GC Root对象强引用了同时我们创建了一个软引用SoftReference对象它本身也是一个对象软引用对象中引用了对象A。 接下来强引用被去掉之后对象A暂时还是处于不可回收状态因为有软引用存在并且内存还够用。 如果内存出现不够用的情况对象A就处于可回收状态可以被垃圾回收器回收。 这样做有什么好处如果对象A是一个缓存平时会保存在内存中如果想访问数据可以快速访问。但是如果内存不够用了我们就可以将这部分缓存清理掉释放内存。即便缓存没了也可以从数据库等地方获取数据不会影响到业务正常运行这样可以减少内存溢出产生的可能性。 特别注意 软引用对象本身也需要被强引用否则软引用对象也会被回收掉。 软引用的使用方法 软引用的执行过程如下 1.将对象使用软引用包装起来new SoftReference对象类型(对象)。 2.内存不足时虚拟机尝试进行垃圾回收。 3.如果垃圾回收仍不能解决内存不足的问题回收软引用中的对象。 4.如果依然内存不足抛出OutOfMemory异常。 代码 /*** 软引用案例2 - 基本使用*/ public class SoftReferenceDemo2 {public static void main(String[] args) throws IOException {byte[] bytes new byte[1024 * 1024 * 100];SoftReferencebyte[] softReference new SoftReferencebyte[](bytes);bytes null;System.out.println(softReference.get());byte[] bytes2 new byte[1024 * 1024 * 100];System.out.println(softReference.get()); // // byte[] bytes3 new byte[1024 * 1024 * 100]; // softReference null; // System.gc(); // // System.in.read();} }添加虚拟机参数限制最大堆内存大小为200m 执行后发现第二个100m对象创建之后需软引用中包含的对象已经被回收了。 软引用对象本身怎么回收呢 如果软引用对象里边包含的数据已经被回收了那么软引用对象本身其实也可以被回收了。 SoftReference提供了一套队列机制 1、软引用创建时通过构造器传入引用队列 2、在软引用中包含的对象被回收时该软引用对象会被放入引用队列 3、通过代码遍历引用队列将SoftReference的强引用删除 代码 /*** 软引用案例3 - 引用队列使用*/ public class SoftReferenceDemo3 {public static void main(String[] args) throws IOException {ArrayListSoftReference softReferences new ArrayList();ReferenceQueuebyte[] queues new ReferenceQueuebyte[]();for (int i 0; i 10; i) {byte[] bytes new byte[1024 * 1024 * 100];SoftReference studentRef new SoftReferencebyte[](bytes,queues);softReferences.add(studentRef);}SoftReferencebyte[] ref null;int count 0;while ((ref (SoftReferencebyte[]) queues.poll()) ! null) {count;}System.out.println(count);} }最终展示的结果是 这9个软引用对象中包含的数据已经被回收掉所以可以手动从ArrayList中去掉这样就可以释放这9个对象。 软引用的缓存案例 使用软引用实现学生信息的缓存能支持内存不足时清理缓存。 代码 package chapter04.soft;import java.lang.ref.ReferenceQueue; import java.lang.ref.SoftReference; import java.util.HashMap; import java.util.Map; /*** 软引用案例4 - 学生信息的缓存*/ public class StudentCache {private static StudentCache cache new StudentCache();public static void main(String[] args) {for (int i 0; ; i) {StudentCache.getInstance().cacheStudent(new Student(i, String.valueOf(i)));}}private MapInteger, StudentRef StudentRefs;// 用于Cache内容的存储private ReferenceQueueStudent q;// 垃圾Reference的队列// 继承SoftReference使得每一个实例都具有可识别的标识。// 并且该标识与其在HashMap内的key相同。private class StudentRef extends SoftReferenceStudent {private Integer _key null;public StudentRef(Student em, ReferenceQueueStudent q) {super(em, q);_key em.getId();}}// 构建一个缓存器实例private StudentCache() {StudentRefs new HashMapInteger, StudentRef();q new ReferenceQueueStudent();}// 取得缓存器实例public static StudentCache getInstance() {return cache;}// 以软引用的方式对一个Student对象的实例进行引用并保存该引用private void cacheStudent(Student em) {cleanCache();// 清除垃圾引用StudentRef ref new StudentRef(em, q);StudentRefs.put(em.getId(), ref);System.out.println(StudentRefs.size());}// 依据所指定的ID号重新获取相应Student对象的实例public Student getStudent(Integer id) {Student em null; // 缓存中是否有该Student实例的软引用如果有从软引用中取得。if (StudentRefs.containsKey(id)) {StudentRef ref StudentRefs.get(id);em ref.get();} // 如果没有软引用或者从软引用中得到的实例是null重新构建一个实例 // 并保存对这个新建实例的软引用if (em null) {em new Student(id, String.valueOf(id));System.out.println(Retrieve From StudentInfoCenter. ID id);this.cacheStudent(em);}return em;}// 清除那些所软引用的Student对象已经被回收的StudentRef对象private void cleanCache() {StudentRef ref null;while ((ref (StudentRef) q.poll()) ! null) {StudentRefs.remove(ref._key);}}// // 清除Cache内的全部内容 // public void clearCache() { // cleanCache(); // StudentRefs.clear(); // //System.gc(); // //System.runFinalization(); // } }class Student {int id;String name;public Student(int id, String name) {this.id id;this.name name;}public int getId() {return id;}public void setId(int id) {this.id id;}public String getName() {return name;}public void setName(String name) {this.name name;} }4.3.2 弱引用 弱引用的整体机制和软引用基本一致区别在于弱引用包含的对象在垃圾回收时不管内存够不够都会直接被回收。在JDK 1.2版之后提供了WeakReference类来实现弱引用弱引用主要在ThreadLocal中使用。 弱引用对象本身也可以使用引用队列进行回收。 package chapter04.weak;import java.io.IOException; import java.lang.ref.WeakReference;/*** 弱引用案例 - 基本使用*/ public class WeakReferenceDemo2 {public static void main(String[] args) throws IOException {byte[] bytes new byte[1024 * 1024 * 100];WeakReferencebyte[] weakReference new WeakReferencebyte[](bytes);bytes null;System.out.println(weakReference.get());System.gc();System.out.println(weakReference.get());} }执行之后发现gc执行之后对象已经被回收了。 4.3.3 虚引用和终结器引用 这两种引用在常规开发中是不会使用的。 虚引用也叫幽灵引用/幻影引用不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用直接内存中为了及时知道直接内存对象不再使用从而回收内存使用了虚引用来实现。 终结器引用指的是在对象需要被回收时终结器引用会关联对象并放置在Finalizer类中的引用队列中在稍后由一条由FinalizerThread线程从队列中获取对象然后执行对象的finalize方法在对象第二次被回收时该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上但是不建议这样做。 package chapter04.finalreference;/*** 终结器引用案例*/ public class FinalizeReferenceDemo {public static FinalizeReferenceDemo reference null;public void alive() {System.out.println(当前对象还存活);}Overrideprotected void finalize() throws Throwable {try{System.out.println(finalize()执行了...);//设置强引用自救reference this;}finally {super.finalize();}}public static void main(String[] args) throws Throwable {reference new FinalizeReferenceDemo();test();test();}private static void test() throws InterruptedException {reference null;//回收对象System.gc();//执行finalize方法的优先级比较低休眠500ms等待一下Thread.sleep(500);if (reference ! null) {reference.alive();} else {System.out.println(对象已被回收);}} }4.4 垃圾回收算法 Java是如何实现垃圾回收的呢简单来说垃圾回收要做的有两件事 1、找到内存中存活的对象 2、释放不再存活对象的内存使得程序能再次利用这部分空间 4.4.1 垃圾回收算法的历史和分类 1960年John McCarthy发布了第一个GC算法标记-清除算法。 1963年Marvin L. Minsky 发布了复制算法。 本质上后续所有的垃圾回收算法都是在上述两种算法的基础上优化而来。 4.4.2 垃圾回收算法的评价标准 Java垃圾回收过程会通过单独的GC线程来完成但是不管使用哪一种GC算法都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World简称STW如果STW时间过长则会影响用户的使用。 如下图用户代码执行和垃圾回收执行让用户线程停止执行STW是交替执行的。 所以判断GC算法是否优秀可以从三个方面来考虑 1.吞吐量 吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值即吞吐量 执行用户代码时间 /执行用户代码时间 GC时间。吞吐量数值越高垃圾回收的效率就越高。 2.最大暂停时间 最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。比如如下的图中黄色部分的STW就是最大暂停时间显而易见上面的图比下面的图拥有更少的最大暂停时间。最大暂停时间越短用户使用系统时受到的影响就越短。 3.堆使用效率 不同垃圾回收算法对堆内存的使用方式是不同的。比如标记清除算法可以使用完整的堆内存。而复制算法会将堆内存一分为二每次只能使用一半内存。从堆使用效率上来说标记清除算法要优于复制算法。 上述三种评价标准堆使用效率、吞吐量以及最大暂停时间不可兼得。 一般来说堆内存越大最大暂停时间就越长。想要减少最大暂停时间就会降低吞吐量。 没有一个垃圾回收算法能兼顾上述三点评价标准所以不同的垃圾回收算法它的侧重点是不同的适用于不同的应用场景。 4.4.3 标记清除算法 标记清除算法的核心思想分为两个阶段 1.标记阶段将所有存活的对象进行标记。Java中使用可达性分析算法从GC Root开始通过引用链遍历出所有存活对象。 2.清除阶段从内存中删除没有被标记也就是非存活对象。 第一个阶段从GC Root对象开始扫描将对象A、B、C在引用链上的对象标记出来 第二个阶段将没有标记的对象清理掉所以对象D就被清理掉了。 优点实现简单只需要在第一阶段给每个对象维护标志位第二阶段删除对象即可。 缺点1.碎片化问题 由于内存是连续的所以在对象被删除之后内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间很有可能这些内存单元的大小过小无法进行分配。 如下图红色部分已经被清理掉了总共回收了9个字节但是每个都是一个小碎片无法为5个字节的对象分配空间。 标记清除算法的缺点 2.分配速度慢。由于内存碎片的存在需要维护一个空闲链表极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。 我们需要用一个链表来维护哪些空间可以分配对象很有可能需要遍历这个链表到最后才能发现这块空间足够我们去创建一个对象。如下图遍历到最后才发现有足够的空间分配3个字节的对象了。如果链表很长遍历也会花费较长的时间。 4.4.4 复制算法 复制算法的核心思想是 1.准备两块空间From空间和To空间每次在对象分配阶段只能使用其中一块空间From空间。 对象A首先分配在From空间 2.在垃圾回收GC阶段将From中存活对象复制到To空间。 在垃圾回收阶段如果对象A存活就将其复制到To空间。然后将From空间直接清空。 3.将两块空间的From和To名字互换。 接下来将两块空间的名称互换下次依然在From空间上创建对象。 完整的复制算法的例子 1.将堆内存分割成两块From空间 To空间对象分配阶段创建对象。 2.GC阶段开始将GC Root搬运到To空间 3.将GC Root关联的对象搬运到To空间 4.清理From空间并把名称互换 优点 吞吐量高复制算法只需要遍历一次存活对象复制到To空间即可比标记-整理算法少了一次遍历的过程因而性能较好但是不如标记-清除算法因为标记清除算法不需要进行对象的移动不会发生碎片化复制算法在复制之后就会将对象按顺序放入To空间中所以对象以外的区域都是可用空间不存在碎片化内存空间。 缺点 内存使用效率低每次只能让一半的内存空间来为创建对象使用。 4.4.5 标记整理算法 标记整理算法也叫标记压缩算法是对标记清理算法中容易产生内存碎片问题的一种解决方案。 核心思想分为两个阶段 1.标记阶段将所有存活的对象进行标记。Java中使用可达性分析算法从GC Root开始通过引用链遍历出所有存活对象。 2.整理阶段将存活对象移动到堆的一端。清理掉存活对象的内存空间。 优点 内存使用效率高整个堆内存都可以使用不会像复制算法只能使用半个堆内存不会发生碎片化在整理阶段可以将对象往内存的一侧进行移动剩下的空间都是可以分配对象的有效空间 缺点 整理阶段的效率不高整理算法有很多种比如Lisp2整理算法需要对整个堆中的对象搜索3次整体性能不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能。 4.4.6 分代垃圾回收算法 现代优秀的垃圾回收算法会将上述描述的垃圾回收算法组合进行使用其中应用最广的就是分代垃圾回收算法(Generational GC)。 分代垃圾回收将整个内存区域划分为年轻代和老年代 我们通过arthas来验证下内存划分的情况 在JDK8中添加-XX:UseSerialGC参数使用分代回收的垃圾回收器运行程序。 在arthas中使用memory命令查看内存显示出三个区域的内存情况。 Eden survivor 这两块区域组成了年轻代。 tenured_gen指的是晋升区域其实就是老年代。 另外还可以选择的虚拟机参数如下 参数名参数含义示例-Xms设置堆的最小和初始大小必须是1024倍数且大于1MB比如初始大小6MB的写法 -Xms6291456 -Xms6144k -Xms6m-Xmx设置最大堆的大小必须是1024倍数且大于2MB比如最大堆80 MB的写法 -Xmx83886080 -Xmx81920k -Xmx80m-Xmn新生代的大小新生代256 MB的写法 -Xmn256m -Xmn262144k -Xmn268435456-XX:SurvivorRatio伊甸园区和幸存区的比例默认为8 新生代1g内存伊甸园区800MB,S0和S1各100MB比例调整为4的写法-XX:SurvivorRatio4-XX:PrintGCDetailsverbose:gc打印GC日志无 代码 package chapter04.gc;import java.io.IOException; import java.util.ArrayList; import java.util.List;/*** 垃圾回收器案例1*/ //-XX:UseSerialGC -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio3 -XX:PrintGCDetails public class GcDemo0 {public static void main(String[] args) throws IOException {ListObject list new ArrayList();int count 0;while (true){System.in.read();System.out.println(count);//每次添加1m的数据list.add(new byte[1024 * 1024 * 1]);}} }使用arthas的memory展示出来的效果 heap展示的是可用堆survivor区每次只有一块能使用所以60 - 4 56m。 1、分代回收时创建出来的对象首先会被放入Eden伊甸园区。 2、随着对象在Eden区越来越多如果Eden区满新创建的对象已经无法放入就会触发年轻代的GC称为Minor GC或者Young GC。 Minor GC会把需要eden中和From需要回收的对象回收把没有回收的对象放入To区。 3、接下来S0会变成To区S1变成From区。当eden区满时再往里放入对象依然会发生Minor GC。 此时会回收eden区和S1(from)中的对象并把eden和from区中剩余的对象放入S0。 注意每次Minor GC中都会为对象记录他的年龄初始值为0每次GC完加1。 4、如果Minor GC后对象的年龄达到阈值最大15默认值和垃圾回收器有关对象就会被晋升至老年代。 当老年代中空间不足无法放入新的对象时先尝试minor gc如果还是不足就会触发Full GCFull GC会对整个堆进行垃圾回收。 如果Full GC依然无法回收掉老年代的对象那么当对象继续放入老年代时就会抛出Out Of Memory异常。 下图中的程序为什么会出现OutOfMemory 从上图可以看到Full GC无法回收掉老年代的对象那么当对象继续放入老年代时就会抛出Out Of Memory异常。 继续使用这段代码测试 //-XX:UseSerialGC -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio3 -XX:PrintGCDetails public class GcDemo0 {public static void main(String[] args) throws IOException {ListObject list new ArrayList();int count 0;while (true){System.in.read();System.out.println(count);//每次添加1m的数据list.add(new byte[1024 * 1024 * 1]);}} }结果如下 老年代已经满了而且垃圾回收无法回收掉对象如果还想往里面放就发生了OutOfMemoryError。 4.5 垃圾回收器 为什么分代GC算法要把堆分成年轻代和老年代首先我们要知道堆内存中对象的特性 系统中的大部分对象都是创建出来之后很快就不再使用可以被回收比如用户获取订单数据订单数据返回给用户之后就可以释放了。老年代中会存放长期存活的对象比如Spring的大部分bean对象在程序启动之后就不会被回收了。在虚拟机的默认设置中新生代大小要远小于老年代的大小。 分代GC算法将堆分成年轻代和老年代主要原因有 1、可以通过调整年轻代和老年代的比例来适应不同类型的应用程序提高内存的利用率和性能。 2、新生代和老年代使用不同的垃圾回收算法新生代一般选择复制算法老年代可以选择标记-清除和标记-整理算法由程序员来选择灵活度较高。 3、分代的设计中允许只回收新生代minor gc如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。 垃圾回收器是垃圾回收算法的具体实现。 由于垃圾回收器分为年轻代和老年代除了G1之外其他垃圾回收器必须成对组合进行使用。 具体的关系图如下 4.5.1 年轻代-Serial垃圾回收器 Serial是是一种单线程串行回收年轻代的垃圾回收器。 回收年代和算法 年轻代 复制算法 优点 单CPU处理器下吞吐量非常出色 缺点 多CPU下吞吐量不如其他垃圾回收器堆如果偏大会让用户线程处于长时间的等待 适用场景 Java编写的客户端程序或者硬件配置有限的场景 4.5.2 老年代-SerialOld垃圾回收器 SerialOld是Serial垃圾回收器的老年代版本采用单线程串行回收 -XX:UseSerialGC 新生代、老年代都使用串行回收器。 回收年代和算法 老年代 标记-整理算法 优点 单CPU处理器下吞吐量非常出色 缺点 多CPU下吞吐量不如其他垃圾回收器堆如果偏大会让用户线程处于长时间的等待 适用场景 与Serial垃圾回收器搭配使用或者在CMS特殊情况下使用 4.5.3 年轻代-ParNew垃圾回收器 ParNew垃圾回收器本质上是对Serial在多CPU下的优化使用多线程进行垃圾回收 -XX:UseParNewGC 新生代使用ParNew回收器 老年代使用串行回收器 回收年代和算法 年轻代 复制算法 优点 多CPU处理器下停顿时间较短 缺点 吞吐量和停顿时间不如G1所以在JDK9之后不建议使用 适用场景 JDK8及之前的版本中与CMS老年代垃圾回收器搭配使用 4.5.4 老年代- CMS(Concurrent Mark Sweep)垃圾回收器 CMS垃圾回收器关注的是系统的暂停时间允许用户线程和垃圾回收线程在某些步骤中同时执行减少了用户线程的等待时间。 参数XX:UseConcMarkSweepGC 回收年代和算法 老年代 标记清除算法 优点 系统由于垃圾回收出现的停顿时间较短用户体验好 缺点 1、内存碎片问题 2、退化问题 3、浮动垃圾问题 适用场景 大型的互联网系统中用户请求数据量大、频率高的场景比如订单接口、商品接口等 CMS执行步骤 1.初始标记用极短的时间标记出GC Roots能直接关联到的对象。 2.并发标记, 标记所有的对象用户线程不需要暂停。 3.重新标记由于并发标记阶段有些对象会发生了变化存在错标、漏标等情况需要重新标记。 4.并发清理清理死亡的对象用户线程不需要暂停。 缺点 1、CMS使用了标记-清除算法在垃圾收集结束之后会出现大量的内存碎片CMS会在Full GC时进行碎片的整理。这样会导致用户线程暂停可以使用-XX:CMSFullGCsBeforeCompactionN 参数默认0调整N次Full GC之后再整理。 2.、无法处理在并发清理过程中产生的“浮动垃圾”不能做到完全的垃圾回收。 3、如果老年代内存不足无法分配对象CMS就会退化成Serial Old单线程回收老年代。 并发线程数 在CMS中并发阶段运行时的线程数可以通过-XX:ConcGCThreads参数设置默认值为0由系统计算得出。 计算公式为(-XX:ParallelGCThreads定义的线程数 3) / 4 ParallelGCThreads是STW停顿之后的并行线程数 ParallelGCThreads是由处理器核数决定的 1、当cpu核数小于8时ParallelGCThreads CPU核数 2、否则 ParallelGCThreads 8 (CPU核数 – 8 )*5/8 我的电脑上逻辑处理器有12个所以ParallelGCThreads 8 12 - 8* 5/8 10ConcGCThreads (-XX:ParallelGCThreads定义的线程数 3) / 4 10 3 / 4 3 最终可以得到这张图 并发标记和并发清理阶段会使用3个线程并行处理。重新标记阶段会使用10个线程处理。 由于CPU的核心数有限并发阶段会影响用户线程执行的性能。 4.5.5 年轻代-Parallel Scavenge垃圾回收器 Parallel Scavenge是JDK8默认的年轻代垃圾回收器多线程并行回收关注的是系统的吞吐量。具备自动调整堆内存大小的特点。 回收年代和算法 年轻代 复制算法 优点 吞吐量高而且手动可控。为了提高吞吐量虚拟机会动态调整堆的参数 缺点 不能保证单次的停顿时间 适用场景 后台任务不需要与用户交互并且容易产生大量的对象。比如大数据的处理大文件导出 常用参数 Parallel Scavenge允许手动设置最大暂停时间和吞吐量。Oracle官方建议在使用这个组合时不要设置堆内存的最大值垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小。 最大暂停时间-XX:MaxGCPauseMillisn 设置每次垃圾回收时的最大停顿毫秒数吞吐量-XX:GCTimeRation 设置吞吐量为n用户线程执行时间 n/n 1自动调整内存大小, -XX:UseAdaptiveSizePolicy设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小 4.5.6 老年代-Parallel Old垃圾回收器 Parallel Old是为Parallel Scavenge收集器设计的老年代版本利用多线程并发收集。 参数 -XX:UseParallelGC 或 ​ -XX:UseParallelOldGC可以使用Parallel Scavenge Parallel Old这种组合。 回收年代和算法 老年代 标记-整理算法 优点 并发收集在多核CPU下效率较高 缺点 暂停时间会比较长 适用场景 与Parallel Scavenge配套使用 4.5.7 G1垃圾回收器 JDK9之后默认的垃圾回收器是G1Garbage First垃圾回收器。Parallel Scavenge关注吞吐量允许用户设置最大暂停时间 但是会减少年轻代可用空间的大小。CMS关注暂停时间但是吞吐量方面会下降。 而G1设计目标就是将上述两种垃圾回收器的优点融合 1.支持巨大的堆空间回收并有较高的吞吐量。 2.支持多CPU并行垃圾回收。 3.允许用户设置最大暂停时间。 JDK9之后强烈建议使用G1垃圾回收器。 G1出现之前的垃圾回收器年轻代和老年代一般是连续的如下图 G1的整个堆会被划分成多个大小相等的区域称之为区Region区域不要求是连续的。分为Eden、Survivor、Old区。Region的大小通过堆空间大小/2048计算得到也可以通过参数-XX:G1HeapRegionSize32m指定(其中32m指定region大小为32M)Region size必须是2的指数幂取值范围从1M到32M。 G1垃圾回收有两种方式 1、年轻代回收Young GC 2、混合回收Mixed GC 年轻代回收 年轻代回收Young GC回收Eden区和Survivor区中不用的对象。会导致STWG1中可以通过参数 -XX:MaxGCPauseMillisn默认200 设置每次垃圾回收时的最大暂停时间毫秒数G1垃圾回收器会尽可能地保证暂停时间。 1、新创建的对象会存放在Eden区。当G1判断年轻代区不足max默认60%无法分配对象时需要回收时会执行Young GC。 2、标记出Eden和Survivor区域中的存活对象 3、根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中年龄1清空这些区域。 G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了。 比如 -XX:MaxGCPauseMillisn默认200每个Region回收耗时40ms那么这次回收最多只能回收4个Region。 4、后续Young GC时与之前相同只不过Survivor区中存活对象会被搬运到另一个Survivor区。 5、当某个存活对象的年龄到达阈值默认15将被放入老年代。 6、部分对象如果大小超过Region的一半会直接放入老年代这类老年代被称为Humongous区。比如堆内存是4G每个Region是2M只要一个大对象超过了1M就被放入Humongous区如果对象过大会横跨多个Region。 7、多次回收之后会出现很多Old老年代区此时总堆占有率达到阈值时 -XX:InitiatingHeapOccupancyPercent默认45%会触发混合回收MixedGC。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。 混合回收 混合回收分为初始标记initial mark、并发标记concurrent mark、最终标记remark或者Finalize Marking、并发清理cleanup G1对老年代的清理会选择存活度最低的区域来进行回收这样可以保证回收效率最高这也是G1Garbage first名称的由来。 G1对老年代的清理会选择存活度最低的区域来进行回收这样可以保证回收效率最高这也是G1Garbage first名称的由来。最后清理阶段使用复制算法不会产生内存碎片。 注意如果清理过程中发现没有足够的空Region存放转移的对象会出现Full GC。单线程执行标记-整理算法此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。 G1 – Garbage First 垃圾回收器 参数1 -XX:UseG1GC 打开G1的开关JDK9之后默认不需要打开 参数2-XX:MaxGCPauseMillis毫秒值 最大暂停的时 回收年代和算法 年轻代老年代 复制算法 优点 对比较大的堆如超过6G的堆回收时延迟可控 不会产生内存碎片 并发标记的SATB算法效率高 缺点 JDK8之前还不够成熟 适用场景 JDK8最新版本、JDK9之后建议默认使用 使用以下代码测试g1垃圾回收器打印出每个阶段的时间: package chapter04.gc;import java.io.IOException; import java.util.ArrayList; import java.util.List;/*** 垃圾回收器案例3*/ //-XX:UseG1GC -Xmn8g -Xmx16g -XX:SurvivorRatio8 -XX:PrintGCDetails -verbose:gc public class GcDemo2 {public static void main(String[] args) throws IOException {int count 0;ListObject list new ArrayList();while (true){//System.out.println(count);if(count % 10240 0){list.clear();} // byte[] bytes new byte[1024 * 1024 * 1];list.add(new byte[1024 * 1024 * 1 / 2]); // System.gc();}} }每个region大小为2m一共有84个young区26个幸存者区。 初始标记花了0.0478秒。 并发标记总共耗时10ms不会产生STW。 总结 垃圾回收器的组合关系虽然很多但是针对几个特定的版本比较好的组合选择如下 JDK8及之前 ParNew CMS关注暂停时间、Parallel Scavenge Parallel Old (关注吞吐量)、 G1JDK8之前不建议较大堆并且关注暂停时间 JDK9之后: G1默认 从JDK9之后由于G1日趋成熟JDK默认的垃圾回收器已经修改为G1所以强烈建议在生产环境上使用G1。
http://www.zqtcl.cn/news/275370/

相关文章:

  • 德阳市住房和城乡建设局网站首页一个服务器可以建多少个网站
  • 建一个电商网站多少钱一起做网店货源app
  • 做网站用lunx代理记账 营销型网站
  • 凡客做网站怎么样WordPress分类目录 前100篇
  • 腾讯wordpress 建站教程本地的上海网站建设公司
  • 深圳市南山区住房和建设局官方网站上海专业网站建设公司站霸网络
  • 建网站的8个详细步骤网站集约化建设讲话
  • 建设局哪个网站查证南京注册公司多少钱
  • 免费的网站制作郑州中森网站建设
  • 网站关键词搜不到了濮阳网络教育
  • 推荐股票的好网站如何做好网站宣传
  • 免费网站模板网大型网络游戏
  • 网站开发语言数据库有几种广东省建设厅官网查询
  • 建新建设集团有限公司网站土巴兔装修公司电话
  • 百度网站审核期时间wordpress如何实现收费会员制
  • delphi 2010 网站开发wordpress 变装小说
  • asp.net电子商务网站前台模板企业所得税优惠政策2021年小微企业
  • 成都网站建设 lkcms深圳做网站哪个公司最好
  • 网站降权处理关于网站建设心得体会
  • 互联网站点与wordpress集成软件
  • 网站页面图片布局如何设计最新热点新闻事件
  • 学网站建设难四会市城乡规划建设局网站
  • 网站源码分享网html代码入门基础
  • 农产品网站开发方案陕西建设网成绩查询
  • 网站效益分析iis添加网站ip地址
  • 宣传海报在什么网站做网站建设的能力
  • 温州网站优化优化课程设置
  • 企业推广网站有哪些做百度推广需要什么条件
  • 如何实现网站的快速排名怎么做网站模板
  • 数据型网站建设wordpress 阅读统计