JVM虚拟机
一、JVM初探
- 谈谈你对jvm的理解或认识?
-
什么是JVM?
JVM(Java Virtual Machine),俗称Java虚拟机。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。
- JVM由JVM运行时数据区(图示中蓝色框包含部分)、执行引擎、本地库接口、本地方法库组成。
- JVM运行时数据区,分为线程共享部分(方法区、堆)和线程隔离区(虚拟机栈、本地方法栈和程序计数器)
-
jvm的位置
jvm运行在操作系统之上(Windows,Linux,mac)
jvm的体系结构
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiIXZ05WZj91YpB3I2EzX4xSZz91ZsAzNfRHLGZkRGZkRfJ3bs92YsAjMfVmepNHLEFnc1A3UiVjQ5dzNBNUW2EXZaZTQClGVF5UMR9Fd4VGdsATNfd3bkFGazxycykFaKdkYzZUbapXNXlleSdVY2pESa9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnLjVGNhNTZ5ImYjRTZ5kzYhVzY3QTN4IGM4kTN4UTZxE2Lc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
类加载过程
1.加载
加载指的是将类的class文件读入到内存,并为之创建一个java.lang.Class对象,也就是说,当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象。
类的加载由类加载器完成,类加载器通常由JVM提供,这些类加载器也是前面所有程序运行的基础,JVM提供的这些类加载器通常被称为系统类加载器。除此之外,开发者可以通过继承ClassLoader基类来创建自己的类加载器。
通过使用不同的类加载器,可以从不同来源加载类的二进制数据,通常有如下几种来源。
从本地文件系统加载class文件,这是前面绝大部分示例程序的类加载方式。
从JAR包加载class文件,这种方式也是很常见的,前面介绍JDBC编程时用到的数据库驱动类就放在JAR文件中,JVM可以从JAR文件中直接加载该class文件。
通过网络加载class文件。
把一个Java源文件动态编译,并执行加载。
类加载器通常无须等到“首次使用”该类时才加载该类,Java虚拟机规范允许系统预先加载某些类。
2.链接
当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入连接阶段,连接阶段负责把类的二进制数据合并到JRE中。类连接又可分为如下3个阶段。
1)验证:
验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致。Java是相对C++语言是安全的语言,例如它有C++不具有的数组越界的检查。这本身就是对自身安全的一种保护。验证阶段是Java非常重要的一个阶段,它会直接的保证应用是否会被恶意入侵的一道重要的防线,越是严谨的验证机制越安全。验证的目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。其主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
四种验证做进一步说明:
**文件格式验证:**主要验证字节流是否符合Class文件格式规范,并且能被当前的虚拟机加载处理。例如:主,次版本号是否在当前虚拟机处理的范围之内。常量池中是否有不被支持的常量类型。指向常量的中的索引值是否存在不存在的常量或不符合类型的常量。
**元数据验证:**对字节码描述的信息进行语义的分析,分析是否符合java的语言语法的规范。
**字节码验证:**最重要的验证环节,分析数据流和控制,确定语义是合法的,符合逻辑的。主要的针对元数据验证后对方法体的验证。保证类方法在运行时不会有危害出现。
符号引用验证:主要是针对符号引用转换为直接引用的时候,是会延伸到第三解析阶段,主要去确定访问类型等涉及到引用的情况,主要是要保证引用一定会被访问到,不会出现类等无法访问的问题。
**2)准备:**类准备阶段负责为类的静态变量分配内存,并设置默认初始值。
3)解析:将类的二进制数据中的符号引用替换成直接引用。说明一下:符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。直接引用:是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的。
3.初始化
初始化是为类的静态变量赋予正确的初始值,准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:private static int a = 10,它的执行过程是这样的,首先字节码文件被加载到内存后,先进行链接的验证这一步骤,验证通过后准备阶段,给a分配内存,因为变量a是static的,所以此时a等于int类型的默认初始值0,即a=0,然后到解析(后面在说),到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10。
类加载时机
类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载如JVM中,同一个类就不会被再次载入了。正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。例如,如果在pg的包中有一个名为Person的类,被类加载器ClassLoader的实例kl负责加载,则该Person类对应的Class对象在JVM中表示为(Person.pg.kl)。这意味着两个类加载器加载的同名类:(Person.pg.kl)和(Person.pg.kl2)是不同的、它们所加载的类也是完全不同、互不兼容的。
JVM预定义有三种类加载器,当一个 JVM启动的时候,Java开始使用如下三种类加载器:
类加载器
当程序主动使用某个类时,如果该类还未被加载到内存中,则JVM会通过加载、连接、初始化3个步骤来对该类进行初始化。如果没有意外,JVM将会连续完成3个步骤,所以有时也把这个3个步骤统称为类加载或类初始化。
作用:加载class文件
类==(Class)==的装载大体上可以分为加载类、连接类和初始化三个阶段,在这三个阶段中,所有的类(class)都是由ClassLoader(类加载器)进行加载的,然后Java虚拟机负责连接、初始化等操作.也就是说,无法通过ClassLoader(类加载器)去改变类的连接和初始化行为.
Java虚拟机会创建三种ClassLoader(类加载器),分别是
BootStrap ClassLoader(启动类加载器)
Extension ClassLoader(扩展类加载器)
APP ClassLoader(应用类加载器,也称为系统类加载器)
此外,每个应用还可以自定义ClassLoader
- 启动类(根)加载器(bootstrap classloader)
- :它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
- 虚拟机自带的加载器
- 扩展类加载器(jdk9取消,改为平台加载器)(extension classloader)
- 它负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。由Java语言实现,父类加载器为null。
- 系统类加载器或应用程序加载器(application classloader)
- 被称为系统(也称为应用)类加载器,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader。
public class jvm {
public static void main(String[] args) {
jvm jvm = new jvm();
jvm jvm1 = new jvm();
jvm jvm2 = new jvm();
Class<? extends jvm> aClass = jvm.getClass();
Class<? extends jvm> aClass1 = jvm1.getClass();
Class<? extends jvm> aClass2 = jvm2.getClass();
System.out.println(aClass);
System.out.println(aClass1);
System.out.println(aClass2);
ClassLoader classLoader = aClass.getClassLoader();
System.out.println(classLoader);//AppClassLoader应用程序加载器,在java。lang包下
System.out.println(classLoader.getParent());//ExtClassLoader扩展类加载器,在rt.jar包下
System.out.println(classLoader.getParent().getParent());//java无法访问
}
}
类加载器加载Class大致要经过如下8个步骤:
- 检测此Class是否载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步。
- 如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。
- 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。
- 请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。
- 当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。
- 从文件中载入Class,成功后跳至第8步。
- 抛出ClassNotFountException异常。
- 返回对应的java.lang.Class对象。
双亲委派机制
是为了保证安全的
他会在类运行之前向上找,例如:当前是App(应用程序加载器) - - > Ext(扩展类加载器) – > BOOT(启动类(根)加载器)
所以,他最终执行的是根加载中的类也就是rt.jar包里的
假设运行ext加载器里的类boot中也有,则最终执行boot中的类。假设boot中没有我们所执行的类则会一成一成向下查找
package lang;
public class String {
public java.lang.String toString() {
return "Hello";
}
public static void main(String[] args) {
String string = new String();
System.out.println(string.toString());
}
}
//无法运行,即使能运行也会报错,找不到main()方法错误
双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式.
即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成。
**双亲委派机制的优势:**采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
沙箱安全机制
沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。组成沙箱的基本组件是字节码校验器(确保Java类文件遵循Java语言规范)和类装载器(通过双亲委派机制保证安全)
Native
- 调用的是和Java无关的底层的操作系统库或者其他语言函数库
- 本地方法接口在本地方法栈中登记标记为native的方法,当执行引擎执行时,本地方法接口调用native方法库
Java是一个跨平台的语言,既然是跨了平台,所付出的代价就是牺牲一些对底层的控制,而Java要实现对底层的控制,就需要借助一些其他语言的帮助,这个就是native的作用。
Native method就是一个java调用非java代码的接口,例如
Thread.start()
方法为本地方法,凡是带了native关键字,说明Java权限已经达不到了,需要调用底层C语言的库。具体体现为native方法会进入本地方法栈,会调用本地方法接口(JNI),然后进入本地方法库
native:凡是带native关键字的就说明java的作用范围达不到了,会去调用本地的一些方法的库(由其他语言编写的例如c语言),
会进入本地方法栈,调用本地方法的本地接口,JNI,
JNI的作用:扩展java语言的的使用,融合不同语言为java所用,最初为:c,c++。
背景:java最初发行的时候,c和c++横行,想要占据一席之地,就必须要有某些必要的c和c++程序
所以jvm就在内存中专门开辟了一块标记区域,Native Method Stack,来登记Native方法
在最终执行的时候,通过JNI加载本地方法库中的方法。
PC寄存器(程序计数器)
- 用来存储指向下一条指令的地址,即将要执行的指令代码,其本质就是一个指针,如果执行的是native方法,那这个指针是空的
- 线程私有(即每个线程对应一个PC寄存器)、内存占的特别少,几乎不存在垃圾回收
方法区
- 所有线程共享、有垃圾回收
- 存放类的模板(类的结构信息,以Class的形式存在,放在方法区中,由类加载器加载class得来),常量(final)、静态变量(static),常量池(1.7以后存放在堆中)
- 方法区是一种规范,在不同虚拟机中实现是不一样的
- 实例变量存在堆中,和方法区无关
主要存放字节码的相关信息,例如常量、静态变量、类元信息(即类的组成信息),如果静态变量是类类型,则保存的是该对象在堆内存中的地址引用。方法区被所有线程共享。逻辑上存在,内存上不存在
栈
我们都知道在Java中栈主要是用来存放局部变量的,严格意义上来讲栈应该叫线程栈,例如当我们在main方法中执行其他的方法时,首先会给main方法分配一个栈内存,再给其他线程分配一块栈内存,每块栈内存都是独立的。线程结束,栈内存也就释放了,对于栈来说,不存在垃圾回收问题
栈帧则是栈内存里的一小块内存区域,例如当main线程执行main方法时,它会在整个JVM栈内存区域里给main方法分配一块栈内存,在这块专属于main线程的栈内存里,又会给main()方法分配一块栈帧用于存储专属于main()方法的局部变量,而如果此时main方法又调用了其他方法,此时还会在这块只属于main线程的栈内存中分配一块栈帧用于存放只属于该方法的局部变量。即栈中存放的是一个个的栈帧,每一个栈帧对应一个被调用的方法。
Java栈与数据结构中的栈是一致的,即先进后出。
栈帧中还主要包括局部变量表、操作数栈、动态链接、方法出口等
局部变量表:用于报错函数的参数及局部变量
操作数栈:主要保存计算过程的中间结果,同时作为计算过程中的变量临时的存储空间。
帧数据区:除了局部变量表和操作数据栈以外,栈还需要一些数据来支持常量池的解析,这里帧数据区保存着
访问常量池的指针,方便计程序访问常量池,另外当函数返回或出现异常时卖虚拟机子必须有一个异常处理表,方便发送异常
的时候找到异常的代码,因此异常处理表也是帧数据区的一部分。
堆
由上图可以看出新生代中存在一个伊甸园区和两个幸存区(基于复制算法,后文讲述),然后是老年代、方法区(永久存储区)
对象一开始会进入伊甸园区,当伊甸园区存满之后,就会触发minor GC垃圾回收机制,将没有链接在GC Roots根节点上的对象(无引对象)当做垃圾回收,存活下来的对象就会进入From区,并且此时对象的分代年龄会加1,当伊甸园区第二次存满时,会再次检查哪些对象是无引对象将其回收机(包括From区),然后将伊甸园区和From区中仍然存活的对象都移到To区中,同时分代年龄加1,之后每一次minor GC时,伊甸园区中的对象会进入From区或者To区,同时From区和To区中的对象会来回循环,当分代年龄等于15时,该对象就会进入老年代(web应用中的线程池或者连接池一般都会进入老年代,再如Spring容器中的bean,由于是以单例模式的存在,因此一般都会进入老年代)。
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC),此时会停止其他的用户线程,将服务器资源专注用于垃圾回收,这个过程称为STW过程,它是非常影响性能的一个过程,也正是在这个过程中会产生大量的卡顿。
三种虚拟机:
- SUN
hotspot
- BEA
JRockit
- IBM
j9vm
- heap,一个jvm只存在一个堆内存,堆内存可以调节大小
- 字符串常量池(jdk1.7后),保存所有引用类型的真实对象和数组
- 堆内存分为三个区域:
- 新生代:类诞生和成长,甚至死亡
- 伊甸去(Eden space):所有对象new出来
- 幸存0区:存放GC幸存对象
- 幸存1区:存放GC幸存对象
幸存区会经过不断GC回收,如果达到一定次数(默认15次)后仍存活,则放到老年代
GC回收主要在伊甸区和老年代
- 老年代:存放新生代幸存区存活下来的数据
-
:常驻内存,存放jdk自身携带的Class对象,Interface元数据,存储的是java运行时的一些环境。永久代(从JDK 8开始,Java开始使用元空间取代永久代,元空间并不在虚拟机中,而是直接使用本地内存)
,关闭虚拟机就会释放这块区域内存空间(不存在垃圾回收
)逻辑上存在,物理上不存在
- 新生代:类诞生和成长,甚至死亡
出现内存满的情况:一个启动类,加载大量第三方jar包。如:tomcat部署了太多应用,大量动态生成的反射类。不断的被加载,直到内存满,就会出现OOM。
默认情况,分配的总内存是电脑内存的:1/4,而初始化内存是:1/64
- jdk1.6之前:永久代,常量池在方法区中
- jdk1.7 :永久代,但是退化,`去永久代`,常量池在堆中
- jdk1.8之后:无永久代,常量池在元空间中
`设置jvm内存大小及查看内存使用情况命令:-Xms:1024m -Xmx:1024m -XX:+PrintGCDetails`
PrintGCDetails:打印GC垃圾回收信息
package test;
public class Demo1 {
public static void main(String[] args) {
long l = Runtime.getRuntime().maxMemory();//返回虚拟机视图使用的最大内存
long l1 = Runtime.getRuntime().totalMemory();//返回jvm初始化的总内存
System.out.println("max = " + l + "字节\t" + (l/(double)1024/1024) + "MB");
System.out.println("total = " + l1 + "字节\t" + (l1/(double)1024/1024) + "MB");
}
}
//默认情况下,jvm分配的最大视图总内存是电脑总内存的1*4,而初始化内存是电脑内存的1/64
// -Xml 1024m -Xmx 1024m -XX:+PrintGCDetails
OOM问题?
1、首先尝试将堆内存的空间扩大看结果,
-Xml 1024m -Xmx 1024m -XX:+PrintGCDetails
假设扩大了空间,又走了原样的程序,还是报错,则是代码出现了问题。可能有了垃圾代码,或者是死循环代码占用了空间。
2、还是出错,则需要分析内存,看一下那个地方出现了问题
3、所以就有了专门的资源分析工具JProfiler,我们可以在idea中下载配置他,来完成对资源的分析
再次运行Dump出现错误的问题,使用命令-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
也可以使用此命令Dump一些其他的错误
为什么要设置两个幸存区?
首先,如果没有幸存区的话会如何?
如果没有幸存区,Eden区每进行一次(Minor)轻 GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。你也许会问,执行时间长有什么坏处?频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。
所以Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历15次Minor GC还能在新生代中存活的对象,才会被送到老年代。
为什么要设置两个幸存区?
设置两个Survivor区最大的好处就是解决了碎片化
为什么一个Survivor区不行?第一部分中,我们知道了必须设置Survivor区。假设现在只有一个survivor区,我们来模拟一下流程:
刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
刚刚新建的对象在Eden中,经历一次轻(Minor) GC,Eden中的存活对象就会被移动到第一块survivor 0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和survivor 0中的存活对象又会被复制送入第二块survivor 1(这个过程非常重要,因为这种复制算法保证了survivor 1中来自survivor 0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。survivor 0和Eden被清空,然后下一轮survivor 0与survivor 1交换角色,如此循环往复。如果对象的复制次数达到15次,该对象就会被送到老年代中。
GC介绍
JVM在进行GC时,并不是对这三个区域统一回收,大部分的时候回收都是在新生代,
- 新生代
- 幸存区(from,to)
- 老年区
GC有两种,一种是轻GC(普通GC)一般只发生在新生代中,一种是重GC(全局GC)发生在全局中。
没次GC都会将Eden活的对移到幸存区中:一旦Eden区被GC后,就会是空的!
当一个对象经历了15次GC后,都还没有死那这个对象就会被·移入老年区,
-XX:-XX:MaxTenuringThreshold=9999通过这个参数可以设置进入老年带的时间;
新生代收集算法主要使用复制算法,老年代收集算法只要使用标记-整理算法。
堆的参数配置
-XX:+PrintGC 每次触发GC的时候打印相关日志
-XX:+UseSerialGC 串行回收
-XX:+PrintGCDetails 更详细的GC日志
-Xms 堆初始值
-Xmx 堆最大可用值
-Xmn 新生代堆最大可用值
-XX:SurvivorRatio 用来设置新生代中eden空间和from/to空间的比例.
含以-XX:SurvivorRatio=eden/from=den/to
总结:在实际工作中,我们可以直接将初始的堆大小与最大堆大小相等,
这样的好处是可以减少程序运行时垃圾回收次数,从而提高效率。
-XX:SurvivorRatio 用来设置新生代中eden空间和from/to空间的比例.
设置最大堆内存
参数: -Xms5m -Xmx20m -XX:+PrintGCDetails -XX:+UseSerialGC -XX:+PrintCommandLineFlags
修改JVM堆内存大小
JAVA_OPTS="-server -Xms800m -Xmx800m -XX:PermSize=256m -XX:MaxPermSize=512m -XX:MaxNewSize=512m"
引用计数法:
给对象的引用进行计数(统计).
默认标记次数 = 15次,当标记 = 0,gc会直接进行回收
对象被引用的话,标记数就会 +1,然后放到 幸存区中,如果还是被频繁使用超过默认的标记次数,那么将会将其放入老年代中。
优点:
引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。客观的说,引用计数算法的实现简单,判定的效率很高,在大部分的情况下是一个不错的算法
标记清除算法
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
标记:控制器从引用根结点开始遍历,标记所有被引用的对象。一般是在对象的Header(头部)中记录为可达对象。
清除: 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header(头部)中没有标记为可达对象,则将其回收(清理)。
缺点
- 效率不算高
- 在进行GC的时候,需要停止整个应用程序,导致用户体验差
- 这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表
**注意:**这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
复制算法
一般出现在幸存区中,这也是两块幸存区运行的基础。
复制算法就是将内存空间按容量分成两块。当这一块内存用完的时候,就将还存活着的对象复制到另外一块上面,然后把已经使用过的这一块一次清理掉。这样使得每次都是对半块内存进行内存回收。内存分配时就不用考虑内存碎片等复杂情况,只要移动堆顶的指针,按顺序分配内存即可,实现简单,运行高效。
优点
- 优秀的吞吐量。
- 可实现高速分配:复制算法不用使用空闲链表。这是因为分块是连续的内存空间,因此,调用这个分块的大小,只需要这个分块大小不小于所申请的大小,移动指针进行分配即可。
- 不会发生碎片化。
- 与缓存兼容。
缺点
- 堆的使用效率低下。
- 不兼容保守式GC算法。
- 递归调用函数。
标记-压缩算法
标记-压缩算法与标记-清理算法类似,只是后续步骤是让所有存活的对象移动到一端,然后直接清除掉端边界以外的内存。
优缺点:该算法可以有效的利用堆,但是压缩需要花比较多的时间成本。
JMM
什么是JMM?
JMM(Java Memory Model的缩写)就是所谓的java内存模型。
作用:允许编译器和缓存以数据在处理器特定的缓存(或寄存器)和主存之间移动的次序拥有重要的特权,除非程序员使用了volatile或synchronized明确请求了某些可见性的保证。
JMM简介
在Java语言规范里面指出了JMM是一个比较开拓性的尝试,这种尝试视图定义一个一致的、跨平台的内存模型,但是它有一些比较细微而且很重要的缺点。其实Java语言里面比较容易混淆的关键字主要是synchronized和volatile,也因为这样在开发过程中往往开发者会忽略掉这些规则,这也使得编写同步代码比较困难。
同步
同步就是在发出一个功能调用的时候,在没有得到响应之前,该调用就不返回。
主要是指代需要其他部件协作处理或者需要协作响应的一些任务处理。
比如有一个线程A,在A执行的过程中,可能需要B提供一些相关的执行数据,当然触发B响应的就是A向B发送一个请求或者说对B进行一个调用操作,如果A在执行该操作的时候是同步的方式,那么A就会停留在这个位置等待B给一个响应消息,在B没有任何响应消息回来的时候,A不能做其他事情,只能等待,那么这样的情况,A的操作就是一个同步的简单说明。
异步
异步就是在发出一个功能调用的时候,不需要等待响应,继续进行它该做的事情,一旦得到响应了过后给予一定的处理,但是不影响正常的处理过程的一种方式。
比如有一个线程A,在A执行的过程中,同样需要B提供一些相关数据或者操作,当A向B发送一个请求或者对B进行调用操作过后,A不需要继续等待,而是执行A自己应该做的事情,一旦B有了响应过后会通知A,A接受到该异步请求的响应的时候会进行相关的处理,这种情况下A的操作就是一个简单的异步操作。
JMM结构规范
JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。
volatile
原理
- 规定线程每次修改变量副本后立刻同步到主内存中,用于保证其它线程可以看到自己对变量的修改
- 规定线程每次使用变量前,先从主内存中刷新最新的值到工作内存,用于保证能看见其它线程对变量修改的最新值
- 为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止指令重排序。
注意:
-
volatile只能保证基本类型变量的内存可见性,对于引用类型,无法保证引用所指向的实际对象内部数据的内存可见性。
)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。**
[外链图片转存中…(img-x5nCrcQx-1632114366472)]
volatile
原理
- 规定线程每次修改变量副本后立刻同步到主内存中,用于保证其它线程可以看到自己对变量的修改
- 规定线程每次使用变量前,先从主内存中刷新最新的值到工作内存,用于保证能看见其它线程对变量修改的最新值
- 为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止指令重排序。
注意:
- volatile只能保证基本类型变量的内存可见性,对于引用类型,无法保证引用所指向的实际对象内部数据的内存可见性。
- volilate只能保证共享对象的可见性,不能保证原子性:假设两个线程同时在做x++,在线程A修改共享变量从0到1的同时,线程B已经正在使用值为0的变量,所以这时候可见性已经无法发挥作用,线程B将其修改为1,所以最后结果是1而不是2。
本文乃是学习之余整理而出的结论,借鉴了许多的大佬的博客,由于太多我就没有一一标注,如有带来不便请先联系我。