ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

JAVA面试:JVM虚拟机

2021-07-06 14:01:18  阅读:128  来源: 互联网

标签:JAVA 对象 虚拟机 线程 内存 JVM Java 加载


JVM虚拟机

Volatile:

参考:https://mp.weixin.qq.com/s/Oa3tcfAFO9IgsbE22C5TEg

看下下面这段代码

public static void main(String[] args) {
    ThreadTest test = new ThreadTest();
    test.start();
    for (; ; ) {
        if (test.isFlag()) {
            System.out.println("我出来了!");
        }
    }
}

class ThreadTest extends Thread {
	private boolean flag = false;

    public boolean isFlag( ) {
        return flag;
    }
    @Override
    public void run() {
        try {
            Thread. sleep( 1000 );
        } catch ( InterruptedException e) {
            e. printStackTrace( );
        }
        flag = true;
        System.out.println("flag=" + flag);
    }

}

你会发现永远不会输出“我出来了!”这句话,按道理线程改了flag变量,主线程也可以访问到的啊?

为什么会出现这种情况,需要聊一下别的东西

现代计算机的内存模型

现代计算机中,CPU的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲

将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题缓存一致性(CacheCoherence)

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。

在这里插入图片描述

JMM(JavaMemoryModel)

JMM:Java内存模型,是Java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别(注意这个跟JVM完全不是一个东西)。

Java内存模型(JavaMemoryModel)描述了Java程序中各种变量(线程共享变量)访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。

JMM有以下规定:

所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。

每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。

线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。

不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

本地内存和主内存的关系:

在这里插入图片描述

正是因为这样的机制,才导致了可见性问题的存在,那我们就讨论下可见性的解决方案。

可见性的解决方案

synchronized同步锁:修饰类、方法、代码块

//方法一:synchronized加锁
public static void main(String[] args) {
    ThreadTest test = new ThreadTest();
    test.start();
    for (; ; ) {
        synchronized (test) {
            if (test.isFlag()) {
                System.out.println("我出来了!");
            }
        }
    }
}

为啥加锁可以解决可见性问题呢?

因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。

而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。具体流程见下图。

在这里插入图片描述

**Volatile修饰共享变量:**保证可见性、禁止指令重排序,无法保证原子性

public static void main(String[] args) {
    ThreadTest test = new ThreadTest();
    test.start();
    for (; ; ) {
        synchronized (test) {
            if (test.isFlag()) {
                System.out.println("我出来了!");
            }
        }
    }
}

class ThreadTest extends Thread {
    //方法二:Volatile修饰共享变量
    private volatile boolean flag = false;

    public boolean isFlag( ) {
        return flag;
    }
    @Override
    public void run() {
        try {
            Thread. sleep( 1000 );
        } catch ( InterruptedException e) {
            e. printStackTrace( );
        }
        flag = true;
        System.out.println("flag=" + flag);
    }
}

Volatile做了啥?

每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,假设有线程A-Z在操作数据Num,如果线程A操作了Num并且写回了主内存,其它已经读取的线程B-Z的工作内存中的Num变量副本就会失效了,线程B-Z要进行数据操作的话需要再次去主内存中读取了。

volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,其它线程立即看到最新的值

计算机层面的缓存一致性协议说明一下volatile实现可见性的原理

当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,举例说明变量在多个CPU之间的共享。

如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准

为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。

Intel的MESI(缓存一致性协议)

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

怎么发现数据是否失效:嗅探

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里

嗅探的缺点:总线风暴

由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS不断循环无效交互会导致总线带宽达到峰值

CAS:Compare and Swap,即比较再交换

所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。


禁止指令重排序

重排序:为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序

重排序的类型有哪些呢?源码到最终执行会经过哪些重排序呢?

图片

一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标,而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。

JMM对底层尽量减少约束,使其能够发挥自身优势。

因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。

一般重排序可以分为如下三种:编制内

编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;

指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

**内存系统的重排序:**由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

这里还得提一个概念,as-if-serial

as-if-serial

不管怎么重排序,单线程下的执行结果不能被改变

编译器、runtime和处理器都必须遵守as-if-serial语义。

Volatile是怎么保证不会被执行重排序:内存屏障

java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表

是否能重排序第二个操作
第一个操作普通读/写volatile读volatile写
普通读/写×
volatile读×××
volatile写××

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障

图片

图片

上面的我提过重排序原则,为了提高处理速度,JVM会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见性。

如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。

从JDK5开始,提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性

happens-before

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。

如果现在我变了flag变成了false,那么后面的那个操作,一定要知道我变了。

聊了这么多,我们要知道Volatile是没办法保证原子性的。

**原子性:**就是一次操作,要么完全成功,要么完全失败。

假设现在有N个线程对同一个变量进行累加也是没办法保证结果是对的,因为读写这个过程并不是原子性的。

要解决也简单,要么用原子类,比如AtomicInteger,要么加锁(记得关注Atomic的底层)。

public class Singleton{
    //可见性和指令重排序都能保证
    public volatile static Singleton INSTANCE = null;
    
    private Singleton(){}
    
    private Singleton newInstance(){
        //第一重检查锁定,是为了正确初始化之后不再触发加锁操作,提高效率
        if(INSTANCE == null){
            synchronized(Singleton.class){
                //第二重检查锁定,是为了避免生成多个对象实例
                //比如1,2,3个线程同时满足第一个判断,在synchronized代码块,
                //线程1会先加锁,2,3会被阻塞在外面,当1完成后实例instance不再为null,
                //但是由于没有判断,2,3会继续执行导致多次实例
                //如果不再进行一次判断,那么1,2,3都会生成实例
                if(INSTANCE == null){
                    //非原子操作
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

为啥要双重检查?如果不用Volatile会怎么样?

我先讲一下禁止指令重排序的好处,实际上创建对象要进过如下几个步骤:

  • 分配内存空间
  • 调用构造器,初始化实例
  • 返回地址给引用

上面我不是说了嘛,是可能发生指令重排序的,那有可能构造函数在对象初始化完成前就赋值完成了,在内存里面开辟了一片存储区域后直接返回内存的引用,这个时候还没真正的初始化完对象。

但是别的线程去判断 INSTANCE != null,直接拿去用了,其实这个对象是个半成品,那就有空指针异常了。

volatile与synchronized的区别

1)volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块

2)volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制

3)volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。

4)volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

synchronized不同的修饰有什么区别?

主要分为 synchronized(this|object) {} 对象锁、 synchronized(类.class) {} 类锁

1)对于静态方法,由于此时对象还未生成,所以只能采用类锁;

2)只要采用类锁,无所谓是哪个类,按顺序访问。那也就是说对任何对象都是同步的,因为具有唯一的对象锁。

//1,2都是类锁
//单例模式DCL就是
public class Singleton{
    public volatile static Singleton INSTANCE = null;
    private Singleton(){}
    
    private Singleton newInstance(){
        if(INSTANCE == null){
            synchronized(Singleton.class){
                if(INSTANCE == null){
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

3)对于对象锁(this/object),如果是同一个实例,就会按顺序访问,但是如果是不同实例,就可以同时访问。

private void sync() {
    //因为是不同的实例,所以在各个线程各自的工作内存中,不会发生资源争夺
    synchronized (new SyncThread()) {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

4)如果对象锁跟访问的对象没有关系,那么就会都同时访问

synchronize修饰的方法和 synchronize(this) 都是锁住自己本身的对象

而synchronize(class) synchronize(object) 都是锁别的对象

详见这两篇文章:

https://www.jianshu.com/p/4c1ed2048985

https://www.cnblogs.com/huansky/p/8869888.html

volatile和synchronized总结

1)volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步

2)volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。

3)volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。

4)volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主内存中读取。

5)volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。

6)volatile可以使得long和double的赋值是原子的。

7)volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。


进程和线程占有的资源:

占有共享独有其他
进程地址空间
全局变量
打开的文件
子进程
信号量
帐户信息
线程栈(128K)
程序计数器
状态(序列化)
寄存器
代码段(code segment)
数据段(data section)
进程打开的文件描述符
信号的处理器
进程的当前目录
进程用户ID和进程组ID
线程ID
线程的堆栈
寄存器组地址
错误返回码
现成的信号屏蔽码
多个线程共享进程的内存地址空间和资源

线程共享的资源包括:

(1) 进程代码段

(2) 进程的公有数据(利用这些数据,线程很容易实现相互之间的通讯,利用可见性)

(3) 进程的所拥有资源

(4) 进程的内存地址

线程独立的资源包括:

(1)线程ID:每个线程都有自己唯一的ID,用于区分不同的线程。

(2)寄存器组的值:当线程切换时,必须将原有的线程的寄存器集合的状态保存,以便重新切换时得以恢复。

(3)线程的堆栈:堆栈是保证线程独立运行所必须的。

(4)错误返回码:由于同一个进程中有很多个线程同时运行,可能某个线程进行系统调用后设置了error值,而在该线程还没有处理这个错误,另外一个线程就在此时被调度器投入运行,这样错误值就有可能被修改。所以,不同的线程应该拥有自己的错误返回码变量。

(5)线程优先级:线程调度的次序(并不是优先级大的一定会先执行,优先级大只是最先执行的机会大)。

sleep()和wait()方法

1)wait()

只能在同步代码控制块内释放锁,来自object类,无需捕获异常
让当前线程等待,直到其它线程调用对象的notify或notifyAll方法

2)sleep()

在程序的任何地方不释放锁,来自类Thread,需要捕获异常
当前正在运行的线程主动放弃CPU,进入睡眠状态


说一下JVM中堆栈的区别?

功能方面:堆是用来存放对象的,栈是用来执行程序的。

共享性:堆是线程共享的栈是线程私有的

空间大小:堆大小远远大于栈。


说一下类装载的执行过程?

类装载分为以下 5 个步骤:

  • 加载:根据查找路径找到相应的 class 文件然后导入;
  • 检查:检查加载的 class 文件的正确性;
  • 准备:给类中的静态变量分配内存空间;
  • 解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址
  • 初始化:对静态变量和静态代码块执行初始化工作。

​ 一般来说,我们把 Java 的类加载过程分为三个主要步骤:加载,连接,初始化,具体行为在 Java 虚拟机规范里有非常详细的定义。

​ 首先是加载过程(Loading),它是 Java 将字节码数据不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,比如 jar 文件,class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。

第二阶段是连接(Linking),这是核心的步骤,简单说是把原始的类定义信息平滑地转入 JVM 运行的过程中。这里可进一步细分成三个步骤:

1,验证(Verification),这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载。

2,准备(Pereparation)创建类或者接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显示初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的 JVM 指令。

3,解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在 Java 虚拟机规范中,详细介绍了类,接口,方法和字段等各方面的解析。

​ 最后是初始化阶段(initialization),这一步真正去**执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。**再来谈谈双亲委派模型,简单说就是当加载器(Class-Loader)试图加载某个类型的时候,除非父类加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型


JVM的主要组成部分及其作用

在这里插入图片描述

JVM包含两个子系统和两个组件

两个子系统:Class loader(类装载器)、Execution engine(执行引擎)
两个组件:Runtime data area(运行时数据区域)、Native Interface(本地接口)

Class loader(类装载器):根据给定的全限定名类名(如:java.lang.Object)来装载class文件运行时数据区域中的方法区

Execution engine(执行引擎):执行classes中的指令线程为引擎Execution Engine的一个实例。

Native Interface(本地接口):本地方法库交互,是其它编程语言交互的接口

Runtime data area(运行时数据区域):这就是我们常说的JVM的内存

作用 :首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区域(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。


Java程序运行机制步骤

  • 首先利用IDE集成开发工具编写Java源代码,源文件的后缀为.java
  • 再利用编译器(javac命令)源代码编译成字节码文件,字节码文件的后缀名为.class
  • 运行字节码的工作是由**解释器(java命令)**来完成的

在这里插入图片描述

从上图可以看,java文件通过编译器变成了.class文件,接下来类加载器又将这些.class文件加载到JVM中。
其实可以一句话来解释:类的加载指的是类加载器将类的class文件中的二进制数据读入到 JVM内存中,将其放在运行时数据区方法区内,然后在区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构


JVM 运行时数据区

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:

在这里插入图片描述

不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的区域分为以下 5 个部分:

程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令

分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成;

Java 虚拟机栈(Java Virtual Machine Stacks):

用于存储局部变量表(用来存储函数参数、局部变量等)、操作数栈(用于配合JVM指令的执行,存储来自局部变量表和类属性的值及中间结果,其中操作数栈中的值可以为直接引用)、动态链接(一个指向当前方法所在类的运行时常量池的引用(用于符号引用解析))、方法出口(返回结果、外部方法的地址等) 等信息。

https://blog.csdn.net/qq_40121580/article/details/107441214

本地方法栈(Native Method Stack):

与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;

Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存

方法区(Methed Area):用于存储已被虚拟机加载

用于存储已被虚拟机加载类信息、常量、静态变量、即时编译后的代码等数据。

PS:

1)在JDK8中,JAVA虚拟机栈和本地方法栈已经合并了

2)每个线程都有属于自己的PC计数器和栈

3)关于常量池

JDK1.6及以前,常量池在方法区,这时的方法区也叫做永久代;

JDK1.7的时候,方法区合并到了堆内存中,这时的常量池也可以说是在堆内存中;

JDK1.8及以后,方法区又从堆内存中剥离出来了,但实现方式与之前的永久代不同,这时的方法区被叫做元空间,常量池就存储在元空间。


深拷贝和浅拷贝

浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址,如果原地址发生改变,那么浅复制出来的对象也会相应的改变。

深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存。


JVM堆栈的区别

物理地址

堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代(即新生代使用复制算法,老年代使用标记——压缩)

栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。

内存分配

堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。

栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的

存放的内容

堆存放的是对象的实例和数组。因此该区更关注的是数据的存储。

栈存放的是:局部变量,操作数栈,返回结果等。该区更关注的是程序方法的执行。

PS:1)静态变量放在方法区 2)静态的对象还是放在堆(实例化对象都在堆)


程序的可见度

堆对于整个应用程序都是共享、可见的(线程共享堆的资源,进程的地址空间)

栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同


对象的创建:JVM HotSpot虚拟机对象探秘

说到对象的创建,首先让我们看看 Java 中提供的几种对象创建方式:

Header解释
使用new关键字调用了构造函数
使用工厂方法,如String str = String.valueOf(23);调用了构造函数
使用Class的newInstance方法调用了构造函数
使用Constructor类的newInstance方法
(java.lang.Class或者java.lang.reflect.Constructor反射)
如:Object obj = Class.forName(“java.lang.Object”).newInstance();
调用了构造函数
使用clone方法没有调用构造函数
使用反序列化,ObjectInputStream的readObject()反序列化类没有调用构造函数

下面是对象创建的主要流程:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qSXWwm2z-1625548824681)(C:\Users\Amamiya\Desktop\面试知识\创建对象.png)]

**类是否加载:虚拟机遇到一条new指令时,先检查常量池(方法区内)**是否已经加载相应的类,如果没有,必须先执行相应的类加载。

内存是否规整:类加载通过后,接下来分配内存。若Java堆中内存是绝对规整的,使用“指针碰撞“方式分配内存;如果不是规整的,就从空闲列表中分配,叫做”空闲列表“方式。

并发处理:划分内存时还需要考虑一个问题——并发,也有两种方式:CAS同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)

初始化:然后内存空间初始化操作,接着是做一些必要的对象设置(元信息、哈希码…),最后执行<init>方法。


为对象分配内存

类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:

  • 指针碰撞:如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。
  • 空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。

选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配的两种方式

处理并发安全问题

对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:

  • 对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性);
  • 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。

内存分配时保证线程安全的两种方式

内存溢出异常

Java会存在内存泄漏吗?请简单描述

内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。

但是,即使这样,Java也还是存在着内存泄漏的情况,java导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景。

垃圾收集器

简述Java垃圾回收机制

在java中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。

GC是什么?为什么要GC

GC 是垃圾收集的意思(Gabage Collection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存

回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动

回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。

垃圾回收的优点和原理。并考虑2种回收机制

java语言最显著的特点就是引入了垃圾回收机制,它使java程序员在编写程序时不再考虑内存管理的问题。

由于有这个垃圾回收机制,java中的对象不再有“作用域”的概念,只有引用的对象才有“作用域”。

垃圾回收机制有效的防止了内存泄露,可以有效的使用可使用的内存。

垃圾回收器通常作为一个单独的低级别的线程运行,在不可预知的情况下对内存堆中已经死亡的或很长时间没有用过的对象进行清除和回收。

程序员不能实时的对某个对象或所有对象调用垃圾回收器进行垃圾回收。

垃圾回收有分代复制垃圾回收、标记垃圾回收、增量垃圾回收。

垃圾回收器的基本原理是什么?垃圾回收器可以马上回收内存吗?有什么办法主动通知虚拟机进行垃圾回收?

对于GC来说,当程序员创建对象时,GC就开始监控这个对象的地址、大小以及使用情况。

通常,GC采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是"可达的",哪些对象是"不可达的"。当GC确定一些对象为"不可达"时,GC就有责任回收这些内存空间。

可以。程序员可以手动执行System.gc(),通知GC运行,但是Java语言规范并不保证GC一定会执行。

Java 中都有哪些引用类型?

  • 强引用:发生 gc 的时候不会被回收。
  • 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。
  • 弱引用:有用但不是必须的对象,在下一次GC时会被回收。
  • 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引用的用途是在 gc 时返回一个通知。

怎么判断对象是否可以被回收?

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。

一般有两种方法来判断:

  • 引用计数器法: 为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;
  • 可达性分析算法: 从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。

在Java中,对象什么时候可以被垃圾回收

当对象对当前使用这个对象的应用程序变得不可触及的时候,这个对象就可以被回收了。
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。

说一下 JVM 有哪些垃圾回收算法?

  • 标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高无法清除垃圾碎片
  • 复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。
  • 标记-压缩算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。该算法可以有效的利用堆,但是压缩需要花比较多的时间成本。
  • 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记压缩算法。

标记-清除算法

标记无用对象,然后进行清除回收。

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:

  • 标记阶段:标记出可以回收的对象。
  • 清除阶段:回收被标记的对象所占用的空间。

标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。

优点:实现简单,不需要对象进行移动。

缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

标记-清除算法的执行的过程如下图所示

img

复制算法

为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

复制算法的执行过程如下图所示

img

标记-整理算法

在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。

优点:解决了标记-清理算法存在的内存碎片问题。

缺点:仍需要进行局部对象移动,一定程度上降低了效率。

标记-整理算法的执行过程如下图所示

img

分代收集算法

当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括年轻代老年代永久代,如图所示:

img

虚拟机类加载机制

简述java类加载机制?

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

描述一下JVM加载Class文件的原理机制

Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。

类装载方式,有两种 :

1.隐式装载, 程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到jvm中,

2.显式装载, 通过class.forname()等方法,显式加载需要的类

Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。

什么是类加载器,类加载器有哪些?

实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器。

主要有一下四种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader)用来加载java核心类库,无法被java程序直接引用。
  2. 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
  3. 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。
  4. 用户自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现。

说一下类装载的执行过程?

类装载分为以下 5 个步骤:

  • 加载:根据查找路径找到相应的 class 文件然后导入;
  • 验证:检查加载的 class 文件的正确性;
  • 准备:给类中的静态变量分配内存空间;
  • 解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址;
  • 初始化:对静态变量和静态代码块执行初始化工作。

什么是双亲委派模型?

在介绍双亲委派模型之前先说下类加载器。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在 JVM 中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将 class 文件加载到 JVM 内存,然后再转化为 class 对象。

img

类加载器分类:

  • 启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分,用来加载Java_HOME/lib/目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库;
  • 其他类加载器:
  • 扩展类加载器(Extension ClassLoader):负责加载\lib\ext目录或Java. ext. dirs系统变量指定的路径中的所有类库;
  • 应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。

当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。

JVM调优

说一下 JVM 调优的工具?

JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款视图监控工具。

  • jconsole:用于对 JVM 中的内存、线程和类等进行监控;
  • jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。

常用的 JVM 调优的参数都有哪些?

  • -Xms2g:初始化推大小为 2g;
  • -Xmx2g:堆最大内存为 2g;
  • -XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;
  • -XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;
  • –XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
  • -XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
  • -XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;
  • -XX:+PrintGC:开启打印 gc 信息;
  • -XX:+PrintGCDetails:打印 gc 详细信息。

标签:JAVA,对象,虚拟机,线程,内存,JVM,Java,加载
来源: https://blog.csdn.net/AmamiyaNen/article/details/118518365

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有