Java基础(面试准备版)

字节码
JVM可以理解的代码就叫做字节码,他不面向任何特定的处理器。并且因为机器处理字节码的速度更高,Java拥有比解释型语言更高的执行效率。
在将.class
文件翻译成机器码这一步中,JVM首先会加载字节码文件,然后通过解释器逐行解释执行。这种方式显然不会有多高的执行速度。在面对一些热点代码时,就会显得比较吃力。因此Java引入了JIT编译器,JIT是运行时编译,在完成第一次编译后会将这段代码保存下来,之后再次运行时,就不需要重新编译了。
HotSpot会采用惰性评估,由于热点代码在整个系统中的占比较小,因此JVM会根据代码每次被执行的情况收集信息,并做出一定的优化,随着执行次数的增多,对应代码块的速度就越快。
- JDK9引入了一种新的编译模式AOT(Ahead of Time Compilation)。它支持将字节码编译成机器码,这样就避免了JIT预热。但如果全部使用AOT,会导致Java的动态特性失效,因为像是动态代理这类动态特性,都是在运行时进行修改或生成。
编译与解释并存:Java首先会将java文件编译成class文件,然后用Java解释器解释class文件
equals()与hashCode()
equals用于比较两个对象是否相等,hashCode用于计算对象的哈希值。equals返回true时,hashCode必然相同,但hashCode相等时,由于哈希冲突的原因,对象不一定相等
String
不可变
String不可变的原因在于,内部的char数组被private修饰,同时也没有向外界暴露修改方法。除此之外,String类被final修饰导致它不能被子类继承,也就避免了子类破坏String的e不可变性。
+运算符
Java重载了String的+
运算符,每次进行字符串拼接时,都会新建一个StringBuilder,然后调用toString方法。因此大量使用字符串拼接是一个非常低效的策略
字符串常量池
字符串常量池是JVM用于减少字符串内存消耗的手段,主要目的是为了防止字符串的重复创建。当我们创建一个字符串对象时,JVM首先会去常量池里寻找是否有可用的已被缓存的对象,如果有,就直接使用,将这个对象赋值给目标。如果没有,就需要先创建一个字符串对象,并将他放入常量池里。
常量折叠
对于可以在编译期确定值的变量,javac会直接将结果求出来作为常量放在生成的代码中。例如,String a = “b” + “c”。会被优化为a = “bc”
SPI
在OOP中,推荐模块之间基于接口编程,这样可以保证调用方模块对于被调用方是无感知的,如果涉及到具体实现,就违反了开闭原则,导致后期修改与维护非常困难。
而为了实现模块在装配时不需要在程序里动态指明,就需要一种服务发现机制。
SPI全称Service Provider Interface
。它定义一组服务提供者需要实现的接口,以此将服务接口和具体服务实现分离开来,将调用方与实现方彻底解耦。
SPI和API都是接口,他们都是将实现方与调用方连接起来的桥梁。区别在于,API由实现方决定,实现方来决定自己要提供什么服务。而SPI则由调用方来决定自己需要哪些服务。
实现
TODO
序列化
序列化:将数据结构或对象转换成二进制字节流
反序列化:将二进制字节流转换成数据结构或对象
序列化和反序列化的目的是为了确保在某台机器上存储的数据后续可以被其他机器取用。
常见的序列化协议
- JDK自带的序列化
不常用,这里只讲讲serialVersionUID
的用处。它就是一个类似版本号的东西,每次反序列化时,都会检查这个类的serialVersionUID
是否和当前类中的一样,如果不一致,说明类出现了变动,不能继续序列化。
如果不想某个变量被序列化,可以使用
transient
修饰它。在反序列化之后,这个变量的值会被设置为默认值。
static
变量因为不属于任何实例,因此永远不会被序列化
- Kryo
Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。
demo:
泛型
泛型擦除
Java使用擦除来实现泛型,也就是说在泛型代码内部无法获得泛型的具体信息,对于程序来说List<Integer>
和List<String>
没有任何区别。这也就意味着我们无法在使用泛型的类中调用泛型的某些方法,哪怕我们传入的泛型确实有这些方法。
如果我们想要使用泛型的方法,那就必须告诉编译器,泛型的界限。比如,如果我们想要对列表中的元素进行排序,就必须要告诉编译器,这里的元素都是可排序的,你可以调用元素中的比较方法。
它的语法格式就是<? extends X>
。这段代码告诉程序,传入的泛型必然是X的子类,因此可以放心调用X中的方法。
由于数组拥有内建的协变类型,因此它可以在编译期和运行时进行内建检查。也就是说我们不能做出
Fruit[] f = new Apple[]
这样的操作。对编译器来说,向f中放入任何fruit的子类都是有意义的,但在运行时程序就会发现数组中的部分元素不符合条件。
一个类不能实现同一个接口的两种变体,因为擦除的原因,两个变体会变成相同的接口。在下面的例子中,Hourly在程序眼里实现了两遍Payable
package generics;
interface Payable<T> {}
class Employee implements Payable<Employee> {}
class Hourly extends Employee implements Payable<Hourly> {}
通配符
有时我们希望建立两个类型间某种向上转型的关系,这时就可以用到通配符。<? extends A>
但如果我们写了如下一段代码
List<? extends Fruit> flist = new ArrayList<>();
flist.add(new Apple());
编译器会在add的时候抛出异常,因为对他来说,flist里面存储的究竟是什么类型并不清楚。因此我们无法向这个list里放入任何东西。
从上面数组的介绍中我们可以看出,它不接受数组类型的向上转型,泛型也一样,我们不能做出类似List<Fruit> l = new List<Apple>
的操作。但如果我们确实想让泛型类向上转型,可以使用通配符来完成List<? extends Fruit> l = new List<Apple>
。对于这个列表而言,get方法可以正常工作,因为他知道取出的元素必然是Fruit的子类,但set方法就无法工作了,因为set的元素可以是继承Fruit的任何事物。
如果我们希望向列表中添加元素,就需要用到逆变。它的语法格式是<? super T>
。这样编译器就知道列表里的元素一定都是T或T的子类,自然也就可以放心添加元素了。
无界通配符
无界通配符是<?>
,它看起来跟raw类型没有什么区别,但实际上,使用无界通配符意味着开发人员希望在这里使用泛型约束,但目前还没有确定好,此时无界通配符就可以成为一个类似占位符的东西。
除此之外,无界通配符会阻止用户向其中set原生类型,因为在运行时,程序很有可能会获取到一个具体的类型,这意味着它必须避免将Orange set到实际以Apple为泛型的实例里。
创建类型的实例
有时我们希望在泛型类中新建泛型的对象,但由于擦除导致的泛型类型不可用,程序无法确定传入的对象是否合法,也无法确定泛型类型是否有需要的构造器。
为此我们可以采取的解决方案是,创建一个工厂类,它接收一个Class参数,并创建指定的对象。而为了解决构造器不可用的问题,Java推荐使用显示工厂Supplier来创建对象,这样就只有那些实现了工厂的类型可以被创建。
自限定类型
我们经常会见到一种非常古怪的泛型用法
class SelfBounded<T extends SelfBounded<T>>
它的实际含义是:基类将自身作为模板,但使用泛型作为模板中所有参数的类型。例子如下:
class SelfBounded<T extends SelfBounded<T>> {
T element;
SelfBounded<T> set(T arg) {
element = arg;
return this;
}
T get() { return element; }
class C extends SelfBounded<C> {
C setAndGet(C arg) {
set(arg);
return get();
}
}
}
自限定的参数意义在于它可以保证类型参数必须与正在被定义的类相同。
除此之外,自限定还可以进行参数协变,如果类A是自限定类,他有一个利用Base类作为参数进行set的方法。当B继承A时,如果它拥有一个Base的子类Derived作为参数的set方法,那么这个set方法将直接覆盖掉父类的set方法,而不是像普通的继承那样只是重载出一个新方法。
Unsafe
Unsafe包主要提供一些用于执行低级别、不安全操作的方法。因为Unsafe使得Java拥有了类似C++指针的能力,因此如果没有妥善使用,会带来安全问题。
此外,Unsafe提供的功能只要依靠本地代码实现,Java代码中只是声明方法头。
如果想要使用Unsafe,方案如下:
利用反射获取Unsafe类中已经完成实例化的单例对象theUnsafe
private static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
log.error(e.getMessage(), e);
return null;
}
}
如果我们直接通过getUnsafe
方法获取对象,会抛出异常,原因在于Java会判断当前类是否由Bootstrap classloader
加载,如果不是的话就会抛出异常。也就是说只有启动类加载器加载的类才能调用Unsafe中的方法。这可以有效防止这些不安全的方法在不可信的代码中被调用。
内存操作
Unsafe可以提供直接操作内存的方法。通过Unsafe的方法分配的内存都是堆外内存。无法进行垃圾回收,因此必须要在finally代码块中进行内存释放。
//分配新的本地空间
public native long allocateMemory(long bytes);
//重新调整内存空间的大小
public native long reallocateMemory(long address, long bytes);
//将内存设置为指定值
public native void setMemory(Object o, long offset, long bytes, byte value);
//内存拷贝
public native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes);
//清除内存
public native void freeMemory(long address);
堆外内存的主要价值在于可以改善GC导致的程序停顿。由于堆外内存直接受操作系统控制而非JVM,因此可以维持较小的对内内存规模,从而在GC时减少回收停顿对于应用的影响。
此外,在IO通信中会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝而且生命周期较短的暂存数据,放在堆外内存带来的效益明显更高。
内存屏障
编译器和CPU会在保证最终输出结果相同的前提下,对指令进行重排序来优化性能。但指令重排序可能导致的一个严重的问题就是会导致CPU高速缓存中的数据与主内存中不一致。
内存屏障的作用就是防止屏障一侧的指令被重排序到另一侧,也就是说内存屏障相当于一个同步点,它会保证屏障前的指令全部被执行后再继续向后执行。
volatile关键字就能提供这样一种能力,不过Unsafe中允许用户手动设置屏障。
//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前
public native void loadFence();
//内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前
public native void storeFence();
//内存屏障,禁止load、store操作重排序
public native void fullFence();
集合
List
List的子类有ArrayList
,Vector
,LinkedList
。其中Vector是线程安全的,其余二者则都是线程不安全的。LinkedList底层使用双向链表实现,不过由于该数据结构只在头插时具备一定优势,因此基本没什么人使用。
ArrayList源码分析
构造器
上面是ArrayList的构造器代码,无参构造器生产的实例在初始条件下容量为0,其他情况则会根据入参调整初始实例的大小。
扩容机制
ArrayList最重要的就是它的扩容机制,下面是相关的代码:
它的相关流程是:
判断当前数组容量是否充足,如果充足,直接插入
如果不充足,转入扩容阶段。首先判断当前数组是否已经被修改过,如果已经被修改过,那么将数组扩容到原大小的1.5倍,这里让数组容量指数级增长的原因是,线性增长在数组大小很大的时候会导致频繁扩容(一个很大的数组往往代表它的增长会很迅速)频繁的内存分配会严重影响程序效率,因此指数级扩容显然是个更加明智的方法。如果不是,初始化数组,并将数组大小设置为10与要求大小中最大的那一个(10是默认容量大小)。
如果数组长度过大,会触发hugeLength方法,但因为这不是热门代码,因此被拆分到另一个方法里去。
最后将数组元素拷贝到新的数组中去(如果需要的话)
小技巧
ArrayList提供了一个
ensureCapacity
方法供用户调用,它需要一个minCapacity
参数。在添加大量元素之前可以先调用这个方法,它会一次性将需要的容量分配到位。避免添加过程中频繁的内存分配。
Map
Map中最常用的就是HashMap,因此这里主要讲解HashMap。HashMap是一个线程不安全的类。同ArrayList一样,我们也可以通过在构造器中传入容量大小设置它的初始容量,不过HashMap会自动将这个容量扩充到2的幂次。
在面对Hash冲突时,HashMap使用拉链式冲突解决办法,将冲突的元素串在一个链表当中。但当一条链表的长度大于阈值(默认为8)并且数组长度大于64时,会将这条链表树化以提升搜索效率。不过当数组小于64时,它会选择数组扩容以及rehash的方法来降低哈希冲突。
TreeMap
除了HashMap之外,Java还提供了TreeMap,相比HashMap,它额外提供了根据键排序的能力以及对集合内元素搜索的能力。
HashMap源码分析
插入元素
上面是HashMap执行插入操作时进行的操作,流程如下:
如果哈希表还处于初始化状态,那就对它进行扩容
如果插入元素的目标位置是空的,直接插入
如果不为空,代表出现哈希冲突,首先判断当前位置的哈希值与key是否相同,如果相同,代表这个链表的头节点被踢了。
如果p已经被树化,调用对应的方法取出要被处理的链表节点。
如果以上两个条件都不满足,代表这个要到链表中寻找是否有重复节点,如果到达了链表的尾端,代表没有重复,可以直接插入,插入后如果发现长度大于阈值了,就准备树化(树化方法里会具体判断究竟该树化还是数组扩容)。如果找到了相同的节点,将原节点取出,准备踢了。
ConcurrentHashMap
ConcurrentHashMap是确保线程安全的Map类。它的底层数据结构同HashMap一样,都是数组+链表+红黑树。
实现线程安全的形式
Segment
这时JDK1.7之前的实现,主要思想是对整个桶数组进行分割,分割出来的每一段都称为一个Segment,每当要执行操作时,调用者就要尝试去获取目标数据所在段的Segment锁。也就是说每一个Segment锁都可以保护一部分数据。不过锁的数量是不可变的,也就是说在数据量非常大的情况下,竞争就会比较明显。
synchronize + CAS
在JDK1.8之后,采用了Node 数组 + 链表 / 红黑树解决冲突
ConcurrentHashMap源码分析
初始化
从上面的代码中可以看出,它使用自旋+CAS完成初始化。其中sizeCtl是一个关键的标志变量,它的含义如下:
- -1 说明正在初始化
- -N 说明有N-1个线程正在进行扩容
- 0 表示 table 初始化大小,如果 table 没有初始化
- > 0 表示 table 扩容的阈值,如果 table 已经初始化。
插入数据
插入数据的步骤如下:
计算哈希值
获取哈希值对应的存储地址,如果桶还是空的,用CAS初始化桶。如果桶内为空,利用CAS放入数据。如果计算出的哈希值判断Map需要扩容(hash == MOVED),进行相应的扩容处理。最后一种情况是,发生哈希冲突,需要向链表/红黑树中插入节点,此时需要使用同步代码块保护链表/红黑树,在同步代码块内执行插入操作。
若节点数量大于8,执行树化操作,这里和HashMap一样。
并发编程
进程与线程
Java中的线程与进程间的关系如下
线程共享进程的堆和方法区(或者说元空间)。但每个线程都有自己的虚拟机栈,本地方法栈和程序计数器。
下面来解释一下这三者私有的原因:
程序计数器:字节码解释器需要通过程序计数器来读取指令。在多线程情况下,当前线程很有可能被临时挂起,在之后恢复的时候就需要程序计数器来帮忙记录程序执行到哪里了。
虚拟机栈:它用于存储一些临时变量,常量池引用等程序私有的东西。因此这些东西不能共享。
本地方法栈:和虚拟机栈发挥的作用非常类似,只不过它是为本地方法服务的。
能否直接调用Thread的run方法启动线程
并不能,Thread的start方法所做的事情并不只是调用run方法。它的实际功能是让这个线程进入就绪状态,这样它才会被分配到时间片,然后执行run方法。如果直接调用run方法,只相当于在调用者的线程中执行了一个普通的方法。
死锁
四大要素:互斥,不可剥夺,占有等待,环路等待
sleep()和wait()对比
前者不会释放锁,而后者会释放锁
前者通常用于暂停执行,后者通常用于线程间交互
前者完成执行后,线程会自动苏醒。后者需要其他线程调用同一个对象上的notify方法来唤醒
wait是让获得对象锁的线程实现等待,也就是说它涉及的是对象,因此必然被定义在对象方法中。
volatile
禁止指令重排序的实战应用
上面是使用双重锁校验的单例模式实现,可以看到不仅使用同步代码块保护了对象的创建过程,还是用volatile修饰了实例。下面简单讲解一下这么做的原因:
创建对象看上去就只有一行代码,但实际上它分为3步:
- 为
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址
这其中由于CPU会进行指令重排序,最终他可能按照1->3->2的顺序执行,如果此时另一个线程在3之后,2之前进入了这个方法并进行判断,就会发现实例已经完成初始化了,但实际上只是引用完成了初始化,实例还没有完全初始化完成。使用volatile可以保证初始化指令不会被重排序,也就保证了正确性。
直接用同步块包裹住整个逻辑也是可以的,此时就不需要volatile了,因为其他线程连判断逻辑都进入不了
volatile可以禁止指令重排序,但对他的操作并不是原子性的!!!
锁
公平锁与非公平锁
公平锁:锁被释放后,先申请的线程得到锁,性能较差,因为为了维护时间上的绝对顺序,上下文切换会比较频繁
非公平锁:锁被释放后,后申请的线程可能会先获取到锁,是随机或按照其他优先级排序的。缺点在于可能会导致线程饥饿。
可中断锁与不可中断锁
- 可中断锁 :获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。
ReentrantLock
就属于是可中断锁。 - 不可中断锁 :一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized
就属于是不可中断锁。
ThreadLocal
ThreadLocal是用于给每个线程绑定专属于它的变量,下面来看看它是如何实现的
public class Thread implements Runnable {
//......
//与此线程有关的ThreadLocal值。由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
//......
}
从上面的代码中可以看出,Thread类里面有两个ThreadLocalMap,map中存储的是(ThreadLocal,Object)键值对。
当我们调用ThreadLocal的set和get方法时,实际上实在操作这个Thread内部的ThreadLocalMap
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
ThreadLocal的数据结构如图
内存泄漏问题
由于ThreadLocalMap中的key是一个弱引用,因此如果ThreadLocal没有被外部强引用,那么key就会被GC清理掉,这样就会出现key为null而value不为null的情况。如果我们不采取一些措施,这些value永远都不会被清理掉,这就导致内存泄漏。
线程池
线程池就是管理一系列线程资源的资源池,每当需要处理任务时,就从线程池里取出一个线程资源去处理。处理完之后的线程不会被销毁,而是会回到线程池。
池化思想的目的就是减少每次获取资源的开销,提高资源利用率。
线程池的好处:
降低资源开销:线程池中的线程可被重复利用
提高响应速度:每当有任务到来,可以直接从线程池中取出线程执行操作,省略了创建线程等一系列复杂操作
提高可管理性:使用线程池可以对线程进行统一的分配,调优和监控
线程池的饱和策略
ThreadPoolExecutor.AbortPolicy:抛出异常,拒绝处理之后来的任务
CallerRunsPolicy:直接使用调用者来执行任务,如果调用者程序已经结束,抛弃该任务
DisardPolicy:不处理新任务,直接丢弃
DiscardOldestPolicy:丢弃最老的任务
处理任务的流程
若当前线程池的核心线程数没有满,直接取出一个线程开始执行任务
若核心线程数已满,且等待队列没满,放到等待队列中去等待执行
若等待队列已满,开始扩容线程池
若线程池中线程数已满,根据饱和策略进行处理
通俗理解:正常情况下,线程池只会使用核心线程进行工作。如果发现等待队列都满了,代表这段时间非常繁忙,此时就开始扩充线程池,如果线程池扩充到最大值还无法应付庞大的任务数,就按照饱和策略处理多余的任务。
源码分析
首先会判断当前工作线程的个数是否少于核心线程数,如果是,就执行这个任务。在addWorker这个方法中会原子性的检查运行状态和工作线程数,避免并发环境下的问题。
如果第一轮没有添加成功,会尝试将任务添加到等待队列中。但添加到等待队列还不算完成,我们需要再次确认线程池的运行状态,避免在线程池无法工作的情况下向里面添加任务。同时,如果有个工作线程刚 好在添加完任务之后结束,就应该直接将新的任务拉去执行
最后一种情况就是核心线程数已满,且等待队列也已经满了,这时候尝试扩充线程池,如果失败就代表线程池已经完全爆满,启动饱和策略。
上面是添加工作线程的操作:
首先判断当前线程池的状态是否可以接收新的任务
接着在循环中进行CAS操作,尝试增加工作线程数,如果在此期间线程池状态发生变更,返回步骤1重新开始。如果CAS失败,通过自旋再次尝试
如果成功增加了工作线程数,就尝试创建工作线程。在创建过程中需要获取全局锁,因为workers是一个HashSet。之后检查线程状态,完成创建后释放锁,并启动线程。
设定线程池大小
在Linux中,CPU分配时间片的单位是线程,进程只是一个容器
线程池中的线程数并非是越大越好。在多线程编程中,线程数一般会大于CPU的核心数,CPU会给每个线程分配时间片。如果线程池中的线程太多,很有可能导致频繁的上下文切换,这会严重阻碍CPU的运行效率。
下面是两个简单且适用较广的公式(N为核心数):
CPU密集型:N+1。比核心数多一个线程是为了防止线程偶发的缺页中断,或者其他原因导致的任务暂停,此时多出来的那个线程就可以顶上去,充分利用CPU资源。
IO密集型:2N。在面对大量IO时,系统会用大量时间处理IO交互,而系统在IO交互时是不会占用CPU资源的,此时就可以将CPU让出来给其他线程。
JMM
Java内存区域和JMM(Java内存模型)的区别:
JVM内存结构和Java虚拟机的运行时区域有关,定义了JVM在运行时如何分区存储程序数据
JMM与并发编程相关,抽象了线程和主内存之间的关系,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性
抽象主内存与本地内存的方式
详见:JMM(Java 内存模型)详解 | JavaGuide(Java面试+学习指南)
happens-before原则
happens-before关系实际上就是一种对事物发生前后顺序的关系的描述,而与实际的绝对时间没有关系。从本质上来讲,他想表达的意思是前一个操作对之后的操作是可见的。
设计思想:
对编译器和处理器的约束尽可能少,只要不影响程序最终运行结果,就允许进行重排序优化。
对于会改变程序执行结果的重排序,必须禁止。
常见规则:
程序顺序规则:一个线程内,按照代码顺序,写在前面的 h-b 与之后的代码
解锁规则:解锁在加锁之前
volatile变量规则:对于volatile修饰的变量,对他的写操作可以被之后所有对他的操作看到
传递规则:A h-b B, B h-b C -> A h-b C
线程启动规则:start方法发生在线程所有其他操作之前
AQS
AQS是一个抽象队列同步器。它的核心思想是,如果被请求的共享资源空闲,就将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,就需要一套线程阻塞等待以及被唤醒时所分配的机制,这个机制使用CLH锁实现。
CLH锁是一个虚拟的双向队列,暂时获取不到锁的线程会被加入到队列中。AQS会将每条请求共享资源的线程封装成一个队列节点来实现锁的分配。每个节点表示一个线程,它保存着线程的引用,在队列中的状态。
接下来用ReentrantLock
为例说明AQS工作方式。AQS使用state变量来表示同步状态,使用volatile修饰,确保它的修改对所有线程可见。
private volatile int state;
state初始值为0,当A线程调用lock方法时,会尝试获取该锁,并将state+1
。此时其他线程再调用lock方法就会失败,直到A线程释放锁,让state-1
。由于A获取的是可重入锁,因此它可以再次获取到这把锁并再次将state+1
。但在释放的时候也必须执行相同次数的减操作。
JVM
方法区
方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。不过JVM只是规定了有这么一个概念以及它的作用,具体实现还是看虚拟机的开发者。
元空间就相当于是一个方法区的实现,它取代了原本的永久代。相比于永久代,它的优势在于:
永久代有一个JVM本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,虽然受本机可用内存限制,元空间仍可能溢出,但概率相对来说小了很多。
元空间存放的是类的元数据类型,这样加载多少类的元数据就不由MaxPermSize控制,而是由系统实际可用空间来控制。
运行时常量池
Class文件中有用于存放编译器生成的各种字面量和符号引用的常量池表。常量池表会再类加载后存放到方法区的运行时常量池中。
创建对象
- 类加载检查
虚拟机在看到一条new指令时,首先去尝试利用这个指令的参数到常量池中定位这个类的符号引用,然后检查这个符号引用代表的类是否已经被加载过、解析和初始化过,如果没有,就要进行类加载。
- 分配内存
接下来要为这个类分配内存,类加载完毕后,虚拟机就可以确定这个类需要分配多少内存。分配内存有两种方法:
- 指针碰撞
这种方法是用于没有内存碎片的情况,它会在内存区中间维护一个指针,一边是已经使用的空间,一边是没有使用的空间,分配内存时只要让指针向没有分配的一侧移动即可。
- 空闲列表
这种方法适合于有内存碎片的情况,虚拟机会维护一个列表,里面记录了哪些空间可用,分配内存是就从可用空间里找一块足够大的分配给对象。
不过在分配内存时还存在并发问题,为了避免多线程同时创建对象时出现丢失数据的情况,JVM采取以下两种方法保证线程安全:
CAS + 失败重试
TLAB:为每一个线程与现在Eden区(新生代区)分配一块内存,线程创建对象时优先使用预分配的内存,如果不够用了,再转用CAS+失败重试。
初始化零值
设置对象头
JVM对对象进行必要的设置,例如该对象属于哪个类,如何找到类的元数据信息、对象的哈希码、GC分代年龄等。
- 执行init方法,也就是构造器
对象的内存布局
对象在内存中的布局可以分为3个区域:对象头、实例数据和对齐填充
对象头包含两部分信息:第一部分用于存储对象自身的运行时数据,另一部分是类型指针,指向这个对象所属的类。
实例数据就是这个对象的有效信息
对齐填充用于占位
对象的访问定位
- 句柄
Java在堆中划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址。句柄中包含了对象的实例数据和对象类型数据各自的地址信息。这么做的好处在于,即使对象发生了移动,句柄地址也不会有什么影响
- 直接指针
reference直接指向对象实例地址,它的效率更高,节省了一次指针定位的时间开销
类加载器
类加载器分类:
- BootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++实现,负责加载
%JAVA_HOME%/lib
目录下的 jar 包和类或者被-Xbootclasspath
参数指定的路径中的所有类。 - ExtensionClassLoader(扩展类加载器) :主要负责加载
%JRE_HOME%/lib/ext
目录下的 jar 包和类,或被java.ext.dirs
系统变量所指定的路径下的 jar 包。 - AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
著作权归所有
原文链接:https://javaguide.cn/java/jvm/classloader.html
加载一个类主要分为以下几步:
加载,连接,初始化,其中连接又分为验证,准备,解析
加载:将class文件加载到内存,将静态数据结构转化成方法区中运行的数据结构,生成Class对象
连接
验证:确保加载的类符合JVM规范和安全,算是一个安全检查
准备:初始化类中的静态变量(分配内存+设置初始值)。这里生成的静态变量实例会直接存放在堆中而非永久代中,因为永久代的GC效率很低,放在堆中可以方便GC。
解析:将符号引用替换为直接引用
- 初始化:调用构造器,整个过程是线程安全的
双亲委派机制
当一个类收到了加载请求时,不会直接尝试自己加载,而是委托给父类去完成。如果父类的加载器无法完成工作,子类加载器才会自行尝试加载
这么做的好处在于,可以防止用户的代码影响JDK代码,因为最高级的类加载器是JDK的加载器,用户无法创建和JDK自带的类全类名完全相同的类。
同时也可以避免类的重复加载(相同的类被不同的类加载器加载会产生不同的类),使用了双亲委派机制之后大部分的类加载都会由最顶级的加载器加载。
- 标题: Java基础(面试准备版)
- 作者: Zephyr
- 创建于 : 2023-02-27 11:36:05
- 更新于 : 2023-03-06 10:07:02
- 链接: https://faustpromaxpx.github.io/2023/02/27/offer-java/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。