【秋招总结】JUC和JVM(秋招总结)

多线程

问:进程和线程的区别?

  • 进程是运行着的程序,是资源分配的最小单位,进程之间是相互独立的;
  • 线程是进程的单位,一个进程可以有多个线程,像是工厂里的流水线,是一条执行路径; 线程之间并不完全独立,共享运行栈和程序计数器,有自己独立的堆和方法区;

问:多线程和多进程的区别?

  • 多进程中占用内存多,而且切换复杂,CPU利用率相对较低;多进程中编程是比较简单的,调试也比较方便;每个进程之间不会相互影响;
  • 多线程中占用内存少,切换简单,CPU利用率高;多线程的话编程复杂,调试也复杂;一个线程挂掉会导致整个线程挂掉;

问:并行和并发?

  • 并发是一段时间内宏观上多个程序同时运行,主要是多个事情,在同一个时间段内同时发生了;但并不是真正意义上的同时发生,因为CPU速度很快,采用时间切片,任务来回切换,所以看上去好像是同时在运行;
  • 并行是在某一个时刻,真正有多个程序在运行,指的是多个事情,在同一时间发生了;并发的多个任务互相抢夺资源,并行的多个任务不会相互抢夺资源;只有多个CPU或一个CPU多核的情况下,才会发生并行,否则,看上去同时发生的,其实都是并发执行;

问:CPU内存模型?

CPU的内存模型:因为CPU的执行速度很快,但是内存的读写速度和CPU比起来就慢很多,所以就在CPU里面设计了一个高速缓存;程序在运行的时候,把数据从内存里复制一份到CPU的高速缓存里,然后CPU就直接与高速缓存进行数据读写,运算结束后,再将高速缓存中的数据刷到主存中;
image

这样在单线程的时候自然是没有问题,但是当有多个线程操作同样的数据时,都会把数据复制到自己的高速缓存里,比如线程1把变量+1了,然后把修改后的值刷进主存,线程2的高速缓存里还是原来未修改的,这就造成了数据的脏读,和数据库里事务的脏读差不多;这就是缓存不一致问题:解决的方法主要有两个:

  • 在总线加Lock锁的方式;CPU和其他部件通信是通过总线进行的,所以可以在总线上加lock锁,每次只允许有1个CPU对变量操作;但是这样在锁的时候其他CPU没有办法执行,效率低;
  • 缓存一致性协议(MESI协议):保证每个缓存中的使用的共享变量的副本是一致的,当CPU写数据的时候,if操作的是共享变量(在其他CPU中存在该变量的副本),会发出信号通知其他CPU将该变量的缓存行置为无效,当其他CPU需要读这个变量的时候,因为缓存行是无效的,所以就会从主存中重新读;

问:说一下并发编程中的3个概念?(原子、可见、有序)

  • 原子性:就是说一组操作要么都执行,要么都不执行;而且在执行的过程中不会被任何因素打断;
  • 可见性;可见性就是说当多个线程访问共享变量的时候,一个线程修改了变量的值后,其他线程能够立马看到;
  • 有序性:程序的执行顺序是按照代码的先后的顺序执行;

多线程中要保证这三个特性才能使程序不出错;

指令重排序:在JVM里,真正执行的时候并不是完全按照代码的先后顺序来进行的,它可能会考虑到代码的优化,为了提高程序运行效率,会发生指令重排序,处理器在执行的时候,会考虑指令之间的数据依赖性,如果指令b会用到指令a的结果,那a一定会在b之前进行;if没有依赖,那就可以发生指令的重排序;这种重排序在单线程的时候不会影响结果,但是多线程的时候就不一定了;比如下面的指令:

线程A中
{
    context = loadContext();  //语句1;
    inited = true;            //语句2;
}

线程B中
{
    if (inited) 
        fun(context);
}

线程A中的语句1和语句2没有相互依赖,可以指令重排,if语句2先执行了,这时候线程B开始了,就会拿到一个还没有初始化的context,这就错误了;

问:Java的内存模型以及如何保证三种特性?

在Java中定义了一种内存模型(Java Memory Model,JMM),和整个CPU内存模型基本一致,所有的变量都存在主存中(类似内存),每个线程都有自己的工作内存(类似前面的高速缓存),线程对变量的所有操作都必须在工作内存中,不能直接对主存操作,而且每个线程不能访问其他线程的工作内存;

  • 原子性:在java中对基本数据类型的读取和赋值是原子性操作,也就是这些操作是不可中断的,要么执行要么不执行;比如x=10,是原子性操作,但是x++就不是原子性操作,这个语句包括3个操作:先读取x的值,然后进行
    加1,再写入新值;在java中可以通过synchronized和lock来实现,保证任一时刻只有一个线程执行一段代码块,也就保证了原子性;
  • 可见性:java中是用volatile关键字来保证可见性的,if变量被volatile关键字修饰,那对这个变量进行操作后值会被立马更新到主存,同时其他线程读取到的这个变量的值也设置为无效,重新从内存中读取,这样多个线程读取的都是一个值;synchronized和lock也可以保证可见性,因为每次只能有一个线程拿到锁然后执行同步代码,在释放锁之前会把修改后的变量刷新到主存中,所以可以保证原子性;
  • 有序性:在java中可以通过volatile关键字来保证有序性,这其中最典型的就是双重检验锁的单例模式:我们把instance设置为volitile,因为instance = new Singleton();这并不是一个原子操作,这里面包含3个阶段:
memory =allocate();    //1:分配对象的内存空间 
ctorInstance(memory);  //2:初始化对象 
instance =memory;     //3:设置instance指向刚分配的内存地址

这3个过程,2和3都依赖1,但是2,3之间没有依赖,所以可以执行指令重排,if执行过程中变为了1,3,2;线程A可能执行完3,进行了赋值,但是还没有初始化,那这时候线程B过来了,判断instance不为null,那就返回使用了,这样就出错了;所以使用volatile修饰后就会插入内存屏障,不会执行重排序;

问:说一下volatile关键字?

if一个变量被volatile关键字修饰了,那其实是有两个作用:

  • 1.保证可见性:不同的线程对这个变量的操作都是可见的,当这个变量的值发生修改后,其他线程能立即看到;
  • 2.保证有序性:禁止指令的重排序;
  • 需要注意的点是volatile关键字并不保证原子性;所以也就不能保证多线程安全

volatile是一种轻量级的同步机制,但是一定注意,volatile不能修饰写入操作依赖当前值的变量,比如i++,i=i+1;因为不保证原子性;此外,因为volatile屏蔽了JVM中的代码优化,所以在效率上比较低;

原理
总之,volatile关键字可以保证可见性和有序性,但是不保证原子性,在JVM底层是通过“内存屏障”来实现的,加入volatile关键字后,会多出一个lock前缀指令,就相当于一个内存屏障,主要有3个功能:
1.能够确保指令重排的时候不会把后面的指令排到内存屏障之前,也不会把前面的指令排到内存屏障之后,也就是当执行到内存屏障中这句指令的时候,前面的操作都已经完成了;
2.它会强制将对缓存的修改立即刷入内存;
3.会导致其他cpu对应的缓存行是无效的;

为什么volatile关键字不能保证原子性?
java中只有对基本类型变量的赋值和读取是原子操作,比如i++这个指令,其实是有3个步骤:读取i的值,执行i+1操作,把i+1赋值给i刷到主存中,比如说AB两个线程都取出来i,然后A进行了i+1操作,之后被阻塞了,B又进行了i+1的操作,并且将新值赋给i后刷回主存,然后由于缓存一致性会将线程A中的i值变为新值,但是这并不影响A还要把刚才的i+1得到的新值赋给i的过程,所以导致了最后结果出错;

问:说一下Synchronized关键字?

Synchronized关键字是用来同步的,主要解决线程安全的问题,能够保证并发编程中的3个特性:

  • 原子性:被Synchronized关键字修饰的语句块内的操作都是原子的;
  • 可见性:在释放锁之前,能够把变量刷回主内存中去,保证可见性;
  • 有序性:一个变量在一个时刻只允许一个线程对其进行操作;

主要可以用来修饰方法和代码块,使它们都变成同步方法和同步代码块。JVM底层都是基于进入(enter)和退出(exit)monitor对象来实现的;

  • 同步方法:同步方法是隐式的,不需要通过字节码指令来控制,在方法调用时,JVM从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志位来区分一个方法是否是同步方法,if这个标志位被设置了,那就先去获取monitor,if获取成功了,那再去执行方法,执行结束后再释放monitor;if获取不成功,那证明monitor对象被其他线程获取,然后当前线程阻塞;
  • 同步代码块:代码块是利用monitorenter和monitorexit这两个字节码指令实现的,位于同步代码块的开始和结束标志位,当执行到monitorenter时,尝试获取monitor对象,if没有加锁或者被当前线程持有,就把锁计数器+1,执行到monitorexit时,锁计数器-1;当锁计数器为0时,释放锁;if获取monitor对象失败,线程进入阻塞状态,直到其他线程释放锁;

通过javap 反编译查看可以发现同步方法是得到ACC_SYNCHRONIZED标志,而对于同步代码块则是得到monitorenter和monitorexit指令;

问:Java中确保线程安全的方法?(Synchronized和Lock、thradlocal和同步,悲观锁和乐观锁CAS)

java中主要有两种思路来解决:

  • 1.发生线程不安全因为有共享资源,所以可以让线程也有资源,不用去共享资源,可以使用ThreadLocal类,为每一个线程维护一个副本变量,从而让线程不再去竞争共享资源;

在每个线程Thread内部有一个ThreadLocal.ThreadLocalMap类型的成员变量threadLocals,这就是用来存储实际变量的副本的,key就是当前实例变量,value就是变量副本;

  • 2.在java里处理线程安全最为常见的手段就是同步:也就是说在多线程访问共享数据的时候,要保证数据在同一时刻只能被一个线程占用;这里又分为两种方法;

    • 阻塞同步,也叫互斥同步或悲观锁,就是说我们总是假设的认为每次对数据操作时,其他线程都会修改这个数据,所以无论共享数据是否真正竞争,这种处理都去加锁,比如最常见的使用synchronized关键字,可以将操作共享数据的代码或者方法声明为synchronized,这样每次就只有一个线程能拿到锁;此外也可以用lock来加锁,也能够保证同步,这种都称为阻塞同步,这样的缺点是其他线程想要访问数据都会阻塞挂起,这样每次阻塞和唤醒都有性能上的消耗; 悲观锁适合写操作多的场景,可以保证写操作时数据正确;
    • 非阻塞同步,或者说是一种乐观锁,这种方法就是说总是认为不会发生线程不安全,也就是认为每次取数据的时候,其他线程不会修改共享的数据,所以不用上锁,在执行完需要更新数据的时候再比较判断其他线程是否修改过数据,一种常见的方法就是CAS(compare and swap),假设不会冲突所有不上锁,if冲突了那就重新来,直到成功为止;在CAS中,有3个操作数,当前值V,期望值E,还有新值new,当操作一个共享变量的时候,比较V和E,if相等,那说明这个过程中没有其他线程修改了这个变量,就把V置为新值new;if不相等,那说明这个过程中有其他线程修改了这个变量,那就改变期望值E重新进行比较。 乐观锁适合读操作多的场景,不加锁的特点能够使其性能提升;

举个例子,比如两个线程操作共享变量a=0;要执行a++;线程A先对变量a进行a++,V=0,E=0,新值new=1;但是在执行的过程中,线程B执行了a++将值改为1了,所以在比较的时候V=1,不等于E了,所以就需要重新比较,将3个操作数更新为V=1,E=1,new=2,这时候if没有其他线程操作了,那就将新值赋给V,修改成功;

Synchronized和Lock的区别?

Synchronized和Lock都可以解决线程安全问题,两者的不同就是Synchronized在执行完相应的同步代码之后,会自动释放同步监视器,也就是锁;而Lock需要手动启动同步(lock())和手动关闭(unlock());

问:什么是自旋锁?

当阻塞或者唤醒一个线程的时候需要去切换CPU的状态,这种状态的转换是很耗费时间的,比如有时候可能状态转换消耗的时间有可能比用户执行代码的时间都要长。所以有时候把一个线程去挂起然后再唤醒的话有点得不偿失,所以可以不必让当前线程去阻塞,而是让其自旋,if在自旋完成后前面锁定同步资源的线程已经释放了锁,那当前线程就可以不必阻塞而是直接获取同步资源,这样就可以避免线程切换的开销。
缺点:自旋等待的过程是占用CPU的,if锁被占用的时间很短,自旋等待的效果就会很好,但是,if锁被占用的时间很长,那就会浪费处理器的资源。所以需要给自旋等待设置一定的限度,超过了以后还没有获得锁,那就把当前线程挂起;
image

问:线程的5种状态?

image

  • 新建:当一个Thread类或子类被声明被创建时,就会处于新建状态;
  • 就绪:新建状态的线程start()后,将进入线程队列等待CPU时间片,这时候已经具备了运行的条件,但是没有分配到CPU资源;
  • 运行:当就绪的状态被调度并获得CPU资源后,进入运行状态;
  • 阻塞:在某些情况下,当前线程被挂起,让出了CPU并且临时终止了自己的执行,就进入了阻塞状态;
  • 死亡:线程完成了全部工作,或者被强制终止或出现异常,当前线程结束,就是死亡状态;

问:什么是守护线程?

在java中主要有两类线程,一个是user thread(用户线程),一个是daemon thread(守护线程)
只要JVM实例中存在任何一个非守护线程没有结束,那守护线程就在工作,当所有的非守护线程结束时,守护线程就会随着JVM一同结束工作;比如说最常见的守护线程就是GC垃圾回收器。当程序里没有运行着的其他线程时,程序就不再产生垃圾,垃圾回收器也就无事可做,就会自动离开,它始终在低级别的状态下运行,用于实时监控和管理系统中的可回收资源;

问:Java实现多线程的方式(有程序/两种实现的区别)?

在java里主要是有两种方法实现多线程:

  • 继承Thread类:定义一个子类继承Thread类,然后在子类中重写Thread类的run方法,然后创建子类对象,也就是创建一个线程对象,调用这个线程对象的start方法,启动线程,调用run方法;

注意不能直接调用run方法,当new一个thread的时候,线程进入了新建状态,调用start()方法,会启动一个线程并且使线程进入就绪状态,然后当分配的时间片到了以后就可以开始运行了,start会执行相应的准备工作,然后自动执行run方法,这是多线程的工作;if直接调用run方法,会把run方法当成一个main线程下的普通方法去执行,不会在某个线程下执行,这不是多线程工作;

  • 实现Runnable接口:首先定义一个子类,然后该子类实现Runnable接口,并且重写run方法,然后创建该子类实现类的对象,然后将该对象作为参数传递到Thread类的构造器中,创建Thread类的对象,然后调用Thread类的start方法,开启线程,调用刚才那个子类的的run方法;

一般更常用runnable接口,可以避免单继承的局限性,同时,多个线程可以共享同一个接口实现类的对象,所以很适合多个相同线程来处理同一份资源的情况;

在新的JDK中,又新增了创建线程的方法:

  • 实现callable接口:首先创建一个实现了callable接口的子类,重写call方法,然后创建该接口实现类的对象,将此对象作为参数传递到FutureTask构造器中,创建FutureTask对象,然后再将futuretask对象作为参数传递到Thread类的构造器中,创建Thread对象,然后调用start;与Runnable接口最大的区别就在于callable接口中call方法可以有返回值,可以通过futuretask.get()来获得;此外也可以抛出异常;并且支持泛型;
  • 使用线程池:提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回去,能够避免频繁的创建销毁、实现一个资源的重复利用。相关API有ExecutorService和Executors;
//方法一:继承Thread类;
class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if(i % 2 == 0){
                 System.out.println(Thread.currentThread().getName() + ":" + i);
            }
        }
    }
}

public class ThreadTest {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        t1.start();
//方法二:实现Runnable接口:
class MThread implements Runnable{
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if(i % 2 == 0){
                System.out.println(Thread.currentThread().getName() + ":" + i);
            }
        }
    }
}
public class ThreadTest1 {
    public static void main(String[] args) {
        MThread mThread = new MThread();
        //线程共享接口实现类;
        Thread t1 = new Thread(mThread);
        t1.setName("线程1");
        t1.start();
        Thread t2 = new Thread(mThread);
        t2.setName("线程2");
        t2.start();
    }

}

问:进程间的通信?

每个进程之间彼此相互独立,拥有各自不同的地址空间,所以在一个进程中的全局变量在其他进程里是看不到的,所以进程之间交换数据需要内核,也就是在内核中开辟一块缓冲区,然后把数据从用户空间拷贝到缓冲区,其他线程再从缓冲区把数据读走,这就是进程间的通信;

  • 管道pipe:是一种半双工的通信,也就是说数据只能在一个方向上流动,并且只能用于亲缘关系的进程通信(比如父子进程或兄弟进程);实质上就是管道一端的进程顺序的将进程写入缓冲区,然后另一端的进程再依次顺序读取数据;
  • 命名管道FIFO:和前面基本一样,但是允许无亲缘关系的进程进行通信;
  • 消息队列MessageQueue:就是一个消息的链表,与管道进行相比,就是为每个消息指定了特定的消息类型,接收的时候根据自定义条件接收特定类型的消息;
  • 共享内存:也就是允许多个线程共享一个指定的存储区,这样的好处是效率高,因为进程可以直接读写内存,不需要进行数据的拷贝;
  • 信号量:信号量是一个计数器,可以用来控制多个进程对共享资源的访问,实现进程之间的互斥和同步,经常是把它作为一种锁机制,防止当一个进程正在访问共享资源的时候,其他进程也访问该资源;比如当某个进程来访问内存1的时候,就把信号量的值设置为0,这时候其他的进程再访问的时候看到信号量为0就知道已经有进程在访问了;
  • 套接字socket:可以实现不同计算机上的进程之间的通信,比如说客户端和服务端;
    • 客户端:1创建socket。根据ip地址和端口号构造socket对象;2打开输入输出流:使用getInputStream和getOutStream;3进行读写操作;4关闭socket;
    • 服务端:1创建ServerSocket并绑定指定端口,用于监听客户端请求,2调用accept。3调用socket的getOutputStream和getInputStream,用于数据的收发;4福安比socket对象;

问:线程间通信方式(wait和notify)?

线程通信就是说当多个线程共同操作共享资源的时候,为了避免资源争夺,互相告知自己的状态;这其中最常用的就是wait和notify方法;

  • wait方法就是当前线程必须等待,放弃拥有的锁,然后等待其他线程来唤醒;
  • notify方法就是用来唤醒的,能够唤醒一个等待当前对象锁的线程; 这两个方法都是获得锁的线程调用的,所以需要放在synchronized修饰的方法或者代码块里;

问:说一下线程池?

线程池是一种池化技术,除了线程池以外,其实还有很多,比如说数据库连接池等,它的底层是维护了一个线程队列,队列里保存着等待状态的线程;主要的目的就是减少了每次都去获取资源的消耗,可以重复利用创建的线程,不用再每次创建和销毁,提高资源的利用率;其次,能够提高响应的速度,任务到的时候不需要再去创建了;还有就是提高线程的可管理性,线程不能无限制的创建,这样既浪费系统资源,又降低系统稳定性,使用线程池就可以统一分配管理;

问:sleep和wait异同?

首先两者都能够使线程进入阻塞状态;

  • sleep是Thread类的方法,可以在任何需要的时候进行调用,调用后线程进入睡眠,休眠结束后进入就绪状态;
  • wait是Object类的方法,调用它的线程必须获得锁,所以wait是在synchronized关键字修饰的代码块或函数中调用的,当调用wait后,会释放锁,开始等待,直到被唤醒;
  • if两个方法都在同步代码块或者同步方法中,sleep是不会释放锁,而wait会释放锁;

JVM

问:介绍一下JVM?

JVM是java的核心部分,是java虚拟机的意思,有人也称为世界上最强大的虚拟机,java的一次编译,到处运行正是靠虚拟机来实现的。

image
Java虚拟机运行时数据区包括很多部分:

  • 程序计数器:是一块较小的内存空间,可以看做是当前线程执行的字节码文件的行号,主要有2个作用,1是通过计数器的值来读取指令,实现流程控制,比如循环、跳转等;2是在多线程的情况下,记录当前线程执行的位置,当线程被切换回来的时候知道运行到哪了;
  • java虚拟机栈:同样也是线程私有的,由一个个栈帧组成,每个方法在执行的时候就会创建栈帧,存储局部变量、方法出口等信息,然后把栈帧压栈,当方法结束调用时,栈帧出栈;
  • 本地方法栈:基本和虚拟机栈一样,区别在于java虚拟机栈为虚拟机执行字节码文件,而本地方法栈为使用到的native方法服务;(native方法就是java调用非java代码比如c、c++的接口);
  • 堆:这是java虚拟机里内存最大的一块区域,是线程共享的,主要用来存放实例化的对象和数组;同时这也是垃圾回收的主要区域,所以有时候也叫GC堆;细分的话堆空间又可以分为老生代和新生代;主要是为了划分空间,为了方面对象和垃圾回收;
    • 老生代主要是存放应用程序中生命周期长的存活对象;
    • 新生代主要存放存活周期短的对象;
  • 方法区:方法区也是共享内存,主要用来存储被虚拟机加载的类信息、常量、静态变量等;

问:堆内存和栈内存?

首先是数据结构中:

  • 栈是一种连续存储的数据结构,具有先进后出的性质;
  • 堆是一种非连续的树型存储结构,每个节点都有一个值,整棵树是经过排序的,特点是根节点的值最小(或最大),而且根节点的子树也是一个堆。经常用来实现优先队列;

其次是在内存中的堆区和栈区:

  • 栈内存空间是由操作系统自动分配和释放的,一般情况下空间有限;主要是用来存放一些基本类型的变量、指令代码还有局部变量;栈内存的存取速度比较快,此外,栈中的数据可以共享;
  • 堆区的话内存空间是手动申请和释放的,比如经常用new关键字来进行分配,可以动态的分配内存大小,堆内存的空间很大,几乎没有限制;堆内存在使用完毕后,是由垃圾回收器进行回收的,

问:类加载机制了解吗?

JVM 把描述类的数据从 Class 文件加载到内存,并对数据进行验证、解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型。
加载:通过一个类的全限定类名获取对应的二进制流,在内存中生成对应的 Class 实例,作为方法区中这个类的访问入口。
验证:确保 Class 文件符合约束,防止因载入有错字节流而遭受攻击。包含:文件格式验证、元数据验证、字节码验证、符号引用验证。
准备:为类静态变量分配内存并设置零值,该阶段进行的内存分配仅包括类变量,不包括实例变量。
解析:将常量池内的符号引用替换为直接引用。
初始化:直到该阶段 JVM 才开始执行类中编写的代码,根据程序员的编码去初始化类变量和其他资源。

问:java的类加载器?

加载指的是将类的class文件读入内存,然后为其创建一个java.lang.class对象;
JDK有3个自带的类加载器:bootstrap classloader,ExtClassLoader,AppClassLoader

  • bootstrapClassLoader:根类加载器:是extclassLoader的父类加载器,默认会加载java环境变量%JAVA_HOME%lib下的jar包和class文件;
  • extclassloader:扩展类加载器:是appclassloader的父类加载器,复杂加载%JAVA_HOME%lib/ext下的jar包和class类;
  • appclassLoader是自定义类加载器的父类,负责加载classpath下的类文件

问:双亲委派机制?

双亲委派的意思是说 如果一个类加载器收到了类加载请求,它并不会自己去加载,而是先把这个请求委托给父类的加载器去执行,if父类加载器还存在父类加载器,那就向上进一步委托,依次递归,请求最终到达顶层的启动类加载器,if父类加载器能够完成类加载任务,就成功返回,if无法完成,子类加载器就会尝试自己去加载;(每个儿子都很懒,有活了先去找父亲,直到父亲也干不了时,儿子再自己干)
优势

  • 这种层次结构可以避免类的重复加载,在JVM中区分不同类,不仅是根据类名,相同的class文件被不同的类加载器加载也是不同的类,所以当父类加载了该类时,子类的加载器就没必要再加载一次了;
  • 安全性的考虑:可以避免用户自己编写的类替换了java中的一些核心类,比如String、Integer等;

问:说一下对象创建过程?

  • 遇到new指令时,首先检查这个指令是否能在方法区里定位到一个类,检查这个类是否已经被加载解析过,如果没有的话,就执行相应的类加载;
  • 类加载完之后,在堆空间为新对象分配内存;
  • 内存空间分配完之后初始化为0,然后填充对象头,把对象是哪个类实例、对象的哈希码、等信息存入对象头;
  • 上面完成之后对虚拟机层面对象已经创建完成,但是对java程序来说,还要执行init方法,按照程序进行初始化;

问:说一下垃圾回收机制?

在java虚拟机的内存中,程序计数器还有栈结构都是跟着线程开始和结束的,所以不太需要考虑回收的问题,主要考虑的是内存中的堆;if不进行回收,那内存迟早被耗完,就没办法运行了;

  • 垃圾标记阶段:需要判断哪些对象是垃圾,需要被回收,总体上就是当一个对象不被任何对象引用时,就判断为垃圾;主要有两种方法:
    • 引用计数法:为每个对象添加一个计数器,当引用一次就+1;引用失效时计数器-1;计数器为0,对象就不用了,简单高效,但是不能解决循环引用的问题;比如两个对象互相引用,始终为1,都不能被回收,其实是一种内存泄漏;
    • 可达性分析:通过“GC roots”的对象作为起始点,然后往下搜索,走过的路径叫做引用链,内存中存活的对象都会直接或间接的与GC root相连,if不相连那就可以被回收了;可以被作为gc root的对象要是活跃的引用,所以可以选用栈和方法区中的引用(因为其不被GC管理),所以不会被GC回收;比如虚拟机栈中的引用对象、本地方法栈中的引用对象、(线程私有,只要线程没终止,就活着),方法区中静态引用对象、方法区中的常量引用对象;
  • 垃圾清除阶段:标记好垃圾后,就需要清除垃圾释放缓存;
    • 标记-清除算法:对堆内存进行遍历,if发现哪个对象没有被标记为可达对象,那就将其回收;这样的话1是效率不高,2是会产生许多不连续的内存碎片,以后如果要分配连续的大内存时,可能就找不到这样的空间了;
    • 复制算法:将内存分为大小相等的两块,每次只用一块,回收的时候把正在使用的那一块里面活着的对象复制到另一块,然后把这块内存回收,两个内存块这样交替;这样子解决了碎片问题,但是将可使用的内存其实就减小了一半;而且还需要频繁的进行复制操作;
    • 标记-整理算法:标记还和原来一样,然后把存活的对象都整理压缩到内存的一端,然后清理所有的其他内存;主要用于老生代;
    • 分代收集算法:目前大部分都采用的这种算法,主要是根据对象的存活周期将内存分为几块并采用不同的垃圾收集算法;一般都是将内存分为新生代(每次垃圾收集都有很多对象死去,只有少量活着,所以用复制算法),老生代(对象的存活率比较高,所以一般使用标记-整理算法)

问:java中内存溢出(outOfMemoryError)和栈溢出(StackOverflowError)?

在java虚拟机规范中描述了两种异常:

  • if线程请求的栈深度大于虚拟机所允许的最大深度,就会抛出stackoverflowError;比如常见的就是方法调用的层次太深,内存不够新建栈帧;比如递归的时候
  • if虚拟机在申请内存的时候无法申请到足够的内存空间,那抛出OutOfMemoryError,这里面可能包括栈内存溢出、堆内存溢出等问题;

问:说一下内存溢出和内存泄漏?

  • 内存溢出(out of memory,OOM)是指堆中没有足够的内存空间了;比如很常见的有栈溢出,像递归这个过程,if没有递归出口那就会栈溢出;
  • 内存泄漏,就是说申请完内存后没办法释放,比如对象不用了,然后也没有被回收,这是一个逐渐的过程,内存被逐步蚕食最后就会导致内存溢出;比如说单例模式,单例的对象和应用程序一样长,如果单例对象引用了另一个对象,那即使这个对象不用了,由于单例仍然引用着它,所以也不能回收,就发生了内存泄漏;再比如说一些资源没有关闭,比如数据库连接,必须手动关闭;
posted @ 2021-11-28 11:33  Curryxin  阅读(936)  评论(0编辑  收藏  举报
Live2D