面试官:小伙子,你给我讲一下java类加载机制和内存模型吧

虚拟机把描述类的数据从 Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。

类的生命周期

加载(Loading)
验证(Verification)
准备(Preparation)
解析(Resolution)
初始化(Initialization)
使用(Using)
卸载(Unloading)

类加载的过程

类的加载过程包括了加载,验证,准备,解析,初始化
类的加载主要分为以下三步:

1. 加载:根据路径找到对应的.class文件

这一步会使用到类加载器
加载是类加载的一个阶段,注意不要混淆。

加载过程完成以下三件事:

通过类的完全限定名称获取定义该类的二进制字节流。
将该字节流表示的静态存储结构转换为方法区的运行时存储结构。
在内存中生成一个代表该类的 Class对象,作为方法区中该类各种数据的访问入口。

2. 连接:

验证:检查待加载的class正确性;
准备:给类的静态变量分配空间,此时静态变量还是零值(还没到初始化的阶段)
解析:将常量池的符号引用转为直接引用
符号引用:
符号引用是用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用:
直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一符号引用在不同虚拟机实例翻译出来的直接引用一半不会相同,如果有了直接引用,那引用目标必定已经在内存中存在。
注意:实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。

3. 初始化:对静态变量和静态代码块执行初始化工作

初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器 () 方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。

总结

在Java中,类装载器把一个类装入Java虚拟机中,要经过三个步骤来完成:加载、连接和初始化,其中链接又可以分成校验、准备和解析三 步,除了解析外,其它步骤是严格按照顺序完成的,各个步骤的主要工作如下:

装载:查找和导入类或接口的二进制数据;
链接:执行下面的校验、准备和解析步骤,其中解析步骤是可以选择的;
校验:检查导入类或接口的二进制数据的正确性;
准备:给类的静态变量分配并初始化存储空间;
解析:将符号引用转成直接引用
初始化:激活类的静态变量的初始化Java代码和静态Java代码块

类初始化的时机

创建类的实例。new,反射,反序列化
使用某类的类方法–静态方法
访问某类的类变量,或赋值类变量
反射创建某类或接口的Class对象。Class.forName(“Hello”);—注意:loadClass调用ClassLoader.loadClass(name,false)方法,没有link,自然没有initialize
初始化某类的子类
直接使用java.exe来运行某个主类。即cmd java 程序会先初始化该类。

类的加载器(ClassLoader)

类加载器虽然只用于实现类的加载动作,但是还起到判别两个类是否相同的作用。
对于任何一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性。
一个java程序由若干个.class文件组成,当程序在运行时,会调用该程序的一个入口函数来调用系统的相关功能,而这些功能都被封装在不同的class文件中。

程序在启动时,并不会一次性加载程序所要用的所有class文件,而是根据程序的需要,通过java的类加载机制来动态加载某个.class文件到内存当中,从而只有class文件被载入到了内存之后,才能被其他class引用,所以类的加载器就是用来动态加载class文件到内存当中用的。

类加载器如何判断是同样的类

java中一个类用 全限定类名标识——包名+类名
jvm中一个类用其 全限定类名+加载器标识——包名+类名+加载器名

类加载器的种类

从虚拟机的角度来分:
一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现(HotSpot虚拟机中),是虚拟机自身的一部分;
另一种就是所有其他的类加载器,这些类加载器都有Java语言实现,独立于虚拟机外部,并且全部继承自java.lang.ClassLoader。
开发者角度来分:
启动(Bootstrap)类加载器:负责将Java_Home/lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以==不允许直接通过引用进行操作。==加载java核心类

扩展(Extension)类加载器:它负责将Java_Home /lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。

应用程序(Application)类加载器:是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(CLASSPATH)中指定的类库。开发者可以直接使用系统类加载器。默认使用

双亲机制

这里的类加载器不是以继承的关系来实现,都是以组合关系复用父类加载器的代码。

定义:
某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

使用双亲委派机制好处在于java类随着它的类加载器一起具备了一种带有优先级的层次关系。

具体的,首先由最顶层的类加载器Bootstrap ClassLoader试图加载,如果没加载到,则把任务转交给Extension ClassLoader试图加载,如果也没加载到,则转交给App ClassLoader 进行加载,如果它也没有加载得到的话,则返回给委托的发起者,由它到指定的文件系统或网络等URL中加载该类。

如果它们都没有加载到这个类时,则抛出ClassNotFoundException异常。否则将这个找到的类生成一个类的定义,并将它加载到内存当中,最后返回这个类在内存中的Class实例对象。

为什么要使用双亲委托这种模型

双亲委托机制可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。

考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。

JVM在搜索类的时候,又是如何判定两个class是相同的呢

JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例加载的。只有两者同时满足的情况下,JVM才认为这两个class是相同的。就算两个class是同一份class字节码,如果被两个不同的ClassLoader实例所加载,JVM也会认为它们是两个不同class。

JAVA内存模型JMM

Java虚拟机规范试图定义一种Java内存模型(JMM),来屏蔽掉各种硬件和操作系统的内存访问差异,让Java程序在各种平台上都能达到一致的内存访问效果。内存模型的作用就是控制一个线程的变量,什么时候对其他线程可见。

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程里面的变量有所不同步,它包含了实例字段、静态字段和构成数组对象的元素,但不包含局部变量和方法参数,因为后者是线程私有的,不会共享,当然不存在数据竞争问题。

JMM规定了所有的变量都存储在主内存(MainMemory)中。每个线程还有自己的工作内存(WorkingMemory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。
Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

内存间交互操作

java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

lock定:作用于主内存的变量。它把一个变量标识为一条线程独占的状态。
unlock解锁:作用于主内存的变量
read读取:作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load载入:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作中内存的变量副本中。
use使用:作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎。每当虚拟机遇到一个需要使用该变量的值的字节码指令时会执行这个操作。
assign赋值:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store存储:作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
write:作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

volatile原理

变量对线程的可见性,比synchronized性能好
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
不能认为,使用了volatile关键字,就认为并发安全。在一些运算中,由于运算并非原子操作,还是会出现同步的问题。

2)禁止进行指令重排序
  普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
  volatile修饰后,会加入内存屏障(指重排序时不能把后面的指令重排序到内存屏障之前的位置)。执行“lock addl $0x0,(%esp)”,这个操作是一个空操作,作用是使得本cpude Cache写入了内存,该写入动作也会引起别的cpu或别的内核无效化其cache,这种操作相当于对cache中的变量store 和write操作,使得对volatile变量的修改对其他cpu立即可见。

内部原理

处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完之后不知道何时会写到内存,如果对声明了Volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

volatile关键字

被volatile修饰的共享变量,就具有了以下两点特性:

保证了不同线程对该变量操作的内存可见性;
禁止指令重排序;

JMM三大特性

原子性

原子性即一个操作或一系列是不可中断的。即使是在多个线程的情况下,操作一旦开始,就不会被其他线程干扰。

比如,对于一个静态变量int x两条线程同时对其赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的。

由jmm来直接保证的原子性变量操作包括read,load,assign,use,store,write,我们大致可以认为基本数据类型的访问读写是具备原子性的,(double和long例外)。此外,synchronized块之间的代码也具有原子性

可见性

可见性指的是,当一个线程修改了共享变量的值后,其他线程能够立即得知这个修改。volatile变量、synchronized,final三个关键字修饰的变量都可保证原子性。

有序性

在Java内存模型中有序性可归纳为这样一句话:如果在本线程内观察,所有操作都是有序的,如果在一个线程中观察另一个线程,所有操作都是无序的。

**有序性是指对于单线程的执行代码,执行是按顺序依次进行的。**但在多线程环境中,则可能出现乱序现象,因为在编译过程会出现“指令重排”,重排后的指令与原指令的顺序未必一致。因此,上面归纳的前半句指的是线程内保证串行语义执行,后半句则指“指令重排”现象和“工作内存与主内存同步延迟”现象。

java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排的语义,而synchronized则是由一个变量在同一时刻只允许一条线程对其进行lock操作这条规则获得的。

指令重排

CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。但代码逻辑之间是存在一定的先后顺序,并发执行时按照不同的执行逻辑会得到不同的结果。

volatile关键词修饰的变量,会禁止指令重排的操作,从而在一定程度上避免了多线程中的问题

volatile不能保证原子性,它只是对单个volatile变量的读/写具有原子性,但是对于类似i++这样的复合操作就无法保证了。

刚提到synchronized,能说说它们之间的区别吗

volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
volatile仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别的;
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性;
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

ABA问题

比如说线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。如果链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化

要解决"ABA问题",我们需要增加一个版本号,在更新变量值的时候不应该只更新一个变量值,而应该更新两个值,分别是变量值和版本号

原子变量

原子变量不使用锁或其他同步机制来保护对其值的并发访问。所有操作都是基于CAS原子操作的。他保证了多线程在同一时间操作一个原子变量而不会产生数据不一致的错误,并且他的性能优于使用同步机制保护的普通变量,譬如说在多线程环境 中统计次数就可以使用原子变量。

多线程的使用场景

有时候使用多线程并不是为了提高效率,而是使得CPU能够同时处理多个事件。

为了不阻塞主线程,启动其他线程来做好事的事情,比如APP中耗时操作都不在UI中做.
实现更快的应用程序,即主线程专门监听用户请求,子线程用来处理用户请求,以获得大的吞吐量.感觉这种情况下,多线程的效率未必高。 这种情况下的多线程是为了不必等待,可以并行处理多条数据。比如JavaWeb的就是主线程专门监听用户的HTTP请求,然后启动子线程去处理用户的HTTP请求。
某种虽然优先级很低的服务,但是却要不定时去做。
比如Jvm的垃圾回收
某种任务,虽然耗时,但是不耗CPU的操作时,开启多个线程,效率会有显著提高。
比如读取文件,然后处理。磁盘IO是个很耗费时间,但是不耗CPU计算的工作。 所以可以一个线程读取数据,一个线程处理数据。肯定比一个线程读取数据,然后处理效率高。因为两个线程的时候充分利用了CPU等待磁盘IO的空闲时间。

最后

欢迎大家关注我的公众号:前程有光,金三银四跳槽面试季,整理了1000多道将近500多页pdf文档的Java面试题资料,文章都会在里面更新,整理的资料也会放在里面。

原文 

https://juejin.im/post/5ef3505fe51d4534640e92fe

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » 面试官:小伙子,你给我讲一下java类加载机制和内存模型吧

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址