javase重点面试题目源码详解
1.==和equal()和hashcode
==和equals函数对于基本类型来说, = = 比较是值,equals不能比较基本类型
对于包装类型来说,== 比较的是对象的引用,就是对象的内存地址。而equals()通常被重写以比较对象的值。
需要注意的是,像Interger这种包装类具有缓存机制,如果在缓存的范围,==的结果可能就是true,因为他们都是指向常量池的同一个对象
对于引用类型来说,==比较的是其对象的内存地址,equals要分为两个情况,看这个类型到底重写了equals函数了没,重写了就按重写的比较,比如String类型,他的equals就是比较的对象的值。然后没有重写的话,equals内部还是使用 ==来比较。没有什么区别。还是比较的对象的内存地址
hashcode函数的作用是获取哈希码,然后确定该对象再hash表中的位置,比如hashmap,hashset,布隆过滤器等都用到了hashcode
hasecode分为好几种哈希函数,有取模的,有进行位运算的。我们在布隆过滤器中使用最好是使用两种hash函数来确定位的位置。
hashCode() 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode() 函数。另外需要注意的是:Object 的 hashCode() 方法是本地方法,也就是用 C 语言或 C++ 实现的
然后hashcode相同,equals不一定相同。hashcode不相同,这个对象一定不相同。这样的话,我们可以将hashcode和equals函数相结合。我们先用hash函数来判断,然后再用equals判断
如果这个对象真的不相同的话,那我们就可以用运行速度快的hash函数早判断。然后出现hash碰撞的时候我们再用equals函数来确定是不是真的相同。这样大大提高了效率,因为hash函数的运算是比equals快的。这也是hashmap,hashset,布隆过滤器的设计原理。
因此,我们在重写equals的时候,hashcode也必须重写,否则就会出现equals相等,但是hash不相同。就会出现重复值的问题
2.BigDecimial处理精度丢失的问题
BigDecimal是Java中用于处理高精度数值计算的类,尤其适用于金融、科学计算等对精度要求极高的场景。
最关键的就是两个字段:
intVal: 一个BigInteger对象,用于存储数值的非标度值 (unscaled value) 。简单来说,就是去掉小数点后的整数值。scale: 一个int类型的整数,表示标度 (scale) 。标度指的是小数点后的位数。例如,对于数值 123.45,intVal是12345,scale是2。
BigDecimal 使用整数来表示数值,避免了浮点数的二进制表示法引入的精度问题。 它通过scale来记录小数点的位置,从而实现对小数的精确表示。
BigDecimal的运算都是基于BigInterger来实现的
-
加减法的时候,调整两个数的sacle,对齐标度,然后将intval相加减,最好创建一个新的BigDecial对象,intval为相加减的结果,scale为调整后的标度
-
乘法,将两个数的intval想乘,然后scale为两个BigDecimal的scale的和
-
除法是最复杂的操作,因为可能产生无限循环小数,
BigDecimal需要提供多种舍入模式 (RoundingMode) 来控制精度,比如ROUND_UP: 向上舍入ROUND_DOWN: 向下舍入ROUND_CEILING: 向正无穷方向舍入ROUND_FLOOR: 向负无穷方向舍入ROUND_HALF_UP: 四舍五入 (大于等于0.5向上舍入)ROUND_HALF_DOWN: 五舍六入 (大于0.5向上舍入)ROUND_HALF_EVEN: 银行家舍入 (四舍六入,五看奇偶,偶舍奇入)BigDecimal会根据指定的舍入模式,计算出精确的结果,并截断到指定的精度。
但是会出现很多个BigDecimal对象:Bigdecimal 是一个immutable类,每次计算都会new一个新的对象。如果在一个循环内多次使用bigdecimal,会生成很多对象,影响性能,建议如果在循化内不要使用string 构造出bigdecimal, 否则生成大量的string对象和bigdecimal对象
3.变量
成员变量&&局部变量对比--变量存储的内存地址对应的任意随机值
- 定义:成员变量是属于类的,局部变量是在代码块或者方法之中的
- 存储:成员变量如果是使用static的话,那这个成员变量属于类,没有的话,在堆。局部变量在栈,栈之中维护了一个局部变量表
- 生存时间:成员变量是对象的一部分,跟对象的生命周期一样,局部变量跟他的方法的生命周期一样
- 默认值:成员变量没有被赋值的话,一般都会是类型的默认值,除非是final修饰的,必须显示的赋值,局部变量不赋值会报错。
| 特性 | 成员变量 (Instance Variable) | 成员变量 (Static Variable) | 局部变量 (Local Variable) |
|---|---|---|---|
| 定义 | 属于类的属性,在类中方法外定义。 | 属于类的静态属性,在类中方法外定义,用static修饰。 | 在方法、代码块(如if、for语句内部)中定义的变量。 |
| 存储 | 存储在堆内存(Heap)中,作为对象的一部分。 | 存储在方法区(Method Area)或元空间(Metaspace)中。(JDK8+之后静态变量从方法区移动到了堆中,但逻辑概念上仍与类相关联) | 存储在Java虚拟机栈(Java Virtual Machine Stack)的栈帧(Stack Frame)的局部变量表中。 |
| 生命周期 | 随着对象的创建而创建,随着对象的销毁而销毁。 | 随着类的加载而创建,随着类的卸载而销毁。(实际上与类的 Class 对象关联) | 随着方法的调用而创建,随着方法的执行结束而销毁。 |
| 默认值 | 存在默认值。如果没有显式赋值,会赋予类型的默认值(如int为0,boolean为false,Object为null)。 | static 变量在类加载的准备阶段就会赋默认值. 如果没有显式赋值,会赋予类型的默认值(如int为0,boolean为false,Object为null)。 | 不存在默认值。必须显式赋值后才能使用,否则编译报错。 |
| final修饰 | final修饰的成员变量必须在对象创建前(构造器或声明时)显式赋值,之后不能修改。 | final static修饰的成员变量必须在类加载完成前(静态代码块或声明时)显式赋值,之后不能修改。 | final修饰的局部变量必须在使用前显式赋值,之后不能修改。 |
| 线程安全 | 线程不安全,每个对象都有一份独立的成员变量副本,如果多个线程修改同一个对象的成员变量,可能导致数据不一致。 | 线程安全,所有该类的对象共享同一个静态变量,需要进行同步处理才能保证线程安全。 | 线程安全,局部变量只在当前线程的栈帧中有效,不同线程之间互不影响。 |
4.String家族三位
String家族的三位分别是String StringBuffer StringBulider,除去String,剩下的两个都是继承自AbstractStringBuilder
其中String是不可变的,StringBuffer和StringBulider是可变的,他们都有append等方法来操作字符串
不同的是StringBuffer是线程安全的,通过同步锁加到方法上,可以多线程操作,而StringBulider是线程不安全的,一般单线程操作。因此StringBulider的性能是最高的
那么为什么String是不可变的呢?
String类中使用final来修饰字符串数组来,导致他的引用类型不能再指向其他的对象,并且数组的私有的。并且没有提供暴露这个字符串的方法
final导致String不能被继承,进而避免了子类破坏String
字符串拼接使用什么?变量少的时候使用+,然后变量多的时候使用StringBulider,防止在循环中使用,建立多个StringBulider对象
在我们JVM的堆中,存在一个字符串常量池,主要就是为了避免字符串的重复的问题
HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。
我们去new一个新的字符串的时候,会看字符串常量池有没有这个字符串,有的话,直接返回该字符串的引用。没有的话,JVM会在常量池中创建该字符,然后返回他的引用,也就是说我们新建了两个对象
5.常见的IO&&拷贝
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
我们的用户进程想要进行IO操作的话,必须通过系统调用来访问内核空间,也就是拷贝,从用户态转变为内核态进行拷贝
从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
常见的IO模型:
同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
在我们的java中,有三种常见的IO
BIO:属于同步堵塞的IO,同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。在客户端连接数量不高的情况下,是没问题的。但是高了就没办法了
NIO:Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。
同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
同步非阻塞 IO,发起一个 read 调用,如果数据没有准备好,这个时候应用程序可以不阻塞等待,而是切换去做一些小的计算任务,然后很快回来继续发起 read 调用,也就是轮询。这个 轮询不是持续不断发起的,会有间隙, 这个间隙的利用就是同步非阻塞 IO 比同步阻塞 IO 高效的地方。
但是这样有问题的,程序需要不断进行IO系统轮询来判断是不是准备好了,然后就出现了我的IO多路复用
IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。
目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。
- 线程通过
select、poll或epoll等系统调用,监听多个文件描述符(File Descriptor, FD) ,一旦某个FD就绪(可读、可写),就通知应用程序。 - select 调用:最大连接数有限制(通常是1024),由
FD_SETSIZE决定。每次调用都需要将FD集合从用户空间拷贝到内核空间,开销大。内核采用轮询方式检查FD是否就绪,效率低。 - poll调用:取消了最大连接数的限制。同样需要将FD集合拷贝到内核空间。
- epoll 调用:基于事件驱动,只关注就绪的FD,避免了无意义的轮询。采用红黑树存储FD,查找效率高。使用
mmap技术,减少了用户空间和内核空间之间的数据拷贝。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
而我们java中的NIO,最重要的三个组件,Selector ,Buffer,Channel
通过多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。然后数据通过Channel让客户端将数据写入到Buffer中去
Channel: 代表一个连接通道,负责数据的读写。Channel类似于传统 I/O 中的流 (Stream),但更加灵活,可以进行双向数据传输。Buffer: 缓冲区,用于存储数据。 NIO 使用缓冲区来读写数据,而不是直接操作流。 Java NIO 支持多种类型的缓冲区,例如ByteBuffer、CharBuffer、IntBuffer等。Selector: 多路复用器,用于监听多个Channel的事件。 一个Selector可以同时监听多个Channel的连接、读、写等事件。 通过 Selector, 只需要一个线程即可管理多个 Channel,实现高效的 I/O 多路复用。- Reactor模式和Proactor模式: 是两种常用的并发编程模式,分别对应I/O多路复用和异步I/O。 Netty 采用了 Reactor模式。
AIO:
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
然后我们需要数据进行传输的时候,就需要对数据进行拷贝。比如用户进程在从硬盘里传输数据的时候,需要从用户态转为内核态然后才能进行拷贝。这样的话,效率比较慢,然后我们就出现了零拷贝技术
传统的数据传输流程中,用户数据通常会经过如下多次拷贝:
硬盘 → 内核缓冲区 → 用户态 → Socket 缓冲区 → 网卡
一般来说文件拷贝是要拷贝四次的,
当用户进程调用read(),用户态无法调用内核态的设备,只能触发系统调用(IO)。这时计算机需要从用户态切换为内核态。
到达内核态之后,计算机通过DMA控制器将数据从磁盘读取出来,放到内核的缓冲区。完成第一次拷贝。
CPU需要将缓冲区的数据拷贝到用户态的缓冲区,完成第二次拷贝,也是read()函数的返回。这时计算器需要从内核态切换为用户态。
因为最终的数据需要通过网卡输出,所以用户进程就需要调用write()函数,CPU将用户缓冲区的数据拷贝到Socket缓冲区,完成第三次拷贝。同时需要再次触发系统调用。这时计算机又需要从用户态切换为内核态。
DMA控制器把数据从Socket缓冲区,拷贝到网卡设备输出,至此完成第四次拷贝。同时需要将内核态切换为用户态,write()函数返回。
而“零拷贝”技术通过内核优化和 API 支持,能避免数据在用户态与内核态间的多次拷贝,从而提升性能。常用技术:
| 技术 | 说明 |
|---|---|
mmap | 将文件映射到内存地址空间,避免文件拷贝 |
sendfile | 直接将文件从磁盘发送到 Socket,避免数据进入用户态 |
writev | 批量写入多个内存区域,减少系统调用 |
DirectByteBuffer(Java NIO) | Java 堆外内存,提高 I/O 性能 |
mmap
-
mmap将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一映射关系。应用程序可以直接读写映射的内存区域,而不需要进行显式的read和write系统调用。 -
原理:
mmap减少了数据在内核空间和用户空间之间的拷贝。 只需要从磁盘拷贝到内核缓冲区,然后用户进程直接从内核缓冲区读取数据,而无需再拷贝到用户空间。 -
适用场景: 适用于需要频繁读写同一文件的场景,例如大型数据库、共享内存等。
-
存在的问题:
mmap对文件读写仍然需要两次上下文切换。- 如果多个进程同时对同一文件进行
mmap映射,可能会导致数据不一致的问题。
-
使用场景: 常用于读取静态资源。
sendfile()
sendfile() 系统调用允许将数据从一个文件描述符 (例如, 文件) 直接传输到另一个文件描述符 (例如, Socket)。 避免了数据在用户空间和内核空间之间的拷贝。
- 用户进程调用
sendfile()系统调用, 指定输入和输出文件描述符。 - 数据通过 DMA 从磁盘读取到内核缓冲区。
- 数据直接从内核缓冲区拷贝到 Socket 缓冲区,或者更优的方式是:只有描述符信息从内核缓冲区拷贝到socket缓冲区。
- 数据通过 DMA 从 Socket 缓冲区传输到网卡。
静态文件服务器(例如 Nginx)通常使用 sendfile() 来将静态文件发送给客户端。只能适用于数据从文件传输到Socket的场景,范围有限
splice() (管道):
splice() 系统调用允许在两个文件描述符之间移动数据,而不需要在用户空间和内核空间之间进行复制。
- 创建两个管道(pipe)对象
- 调用 splice() 系统调用,将数据从输入文件描述符读取到第一个管道.
- 调用 splice() 系统调用,将数据从管道数据写到socket 。
适用于需要数据传输与转换(类似于Linux的管道操作)的场景
Direct I/O:
Direct I/O 允许用户进程绕过内核缓冲区 (Page Cache), 直接访问磁盘。
- 用户进程发起 Direct I/O 请求。
- 数据通过 DMA 直接从磁盘传输到用户进程的缓冲区。
- 需要用户进程自己管理缓存,增加了开发的复杂性。
- 可能影响系统的整体性能, 因为绕过了 Page Cache。 (Page Cache 可以缓存热点数据,提高访问速度)。
大型数据库(例如 Oracle)通常使用 Direct I/O 来进行数据读写, 因为数据库有自己的缓存管理机制。
DirectByteBuffer (Java NIO):
- 是Java NIO 提供的一种堆外内存分配方式,它允许JVM直接在操作系统本地内存(堆外内存)中分配缓冲区,而不是在JVM堆中分配。
- 避免了数据从JVM堆内存拷贝到直接内存 (Native memory) 的过程。
- 适用场景: 适用于需要高效I/O的场景,例如网络服务器、大数据处理等。
- 原理:
DirectByteBuffer并不是真正意义上的零拷贝,因为它仍然需要在用户空间和内核空间之间进行数据拷贝。 但是,它可以减少一次数据拷贝,从而提高I/O性能。 通过调用操作系统的read方法,将数据从IO端口读取到这个直接内存。
好处:
- 减少数据拷贝次数: 降低CPU的开销, 提高I/O效率。
- 减少上下文切换次数: 降低系统开销, 提高并发能力。
- 提高数据传输速度: 缩短响应时间, 提供更好的用户体验。
6.Java中的值传递
- 值传递:方法接收的是实参值的拷贝,会创建副本。
- 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。
但是在java中只有值传递
比如我们设定一个简单的swap方法,交换值得方法,num1=a num2=b
在 swap() 方法中,a、b 的值进行交换,并不会影响到 num1、num2。因为,a、b 的值,只是从 num1、num2 的复制过来的。也就是说,a、b 相当于 num1、num2 的副本,副本的内容无论怎么修改,都不会影响到原件本身。
再比如
我们设定一个swap方法,交换两个Person参数
然后我们发现swap 方法的参数 person1 和 person2 只是拷贝的实参 xiaoZhang 和 xiaoLi 的地址。因此, person1 和 person2 的互换只是拷贝的两个地址的互换罢了,并不会影响到实参 xiaoZhang 和 xiaoLi 。
java值传参:
- 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。
- 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。
7.序列化&&反序列化
- 序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式
- 反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程
序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。
下面是序列化和反序列化常见应用场景:
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化
OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。
然后我们有好几种序列化的方式,jdk自带的效率低且有安全问题,比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
- jdk自带的序列化方式,只需要实现Serializable接口即可,我们一般会加上一个私有静态final的变量,serialVersionUID。是类似于版本控制的效果,如果
serialVersionUID不一致则会抛出InvalidClassException异常。强烈推荐每个序列化类都手动指定其serialVersionUID,如果不手动指定,那么编译器会动态生成默认的serialVersionUID。serialVersionUID是一个特例,serialVersionUID的序列化做了特殊处理。关键在于,serialVersionUID不是作为对象状态的一部分被序列化的,而是被序列化机制本身用作一个特殊的“指纹”或“版本号” - 对于我们不想进行序列化的变量,可以使用
transient关键字修饰。阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被transient修饰的变量值不会被持久化和恢复。static变量因为不属于任何对象(Object),所以无论有没有transient关键字修饰,均不会被序列化。除了serialVersionUID以外。 - Kryo ,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。使用的时候也是需要实现Serializer接口,然后分别去重写serialize方法和deserialize方法
- Protobuf,性能还比较优秀,也支持多种语言,同时还是跨平台的。就是在使用中过于繁琐,因为你需要自己定义 IDL 文件和生成对应的序列化代码。这样虽然不灵活,但是,另一方面导致 protobuf 没有序列化漏洞的风险。
- Protostuff,protostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。
这些反序列化的话,会有漏洞
许多序列化协议都存在反序列化漏洞,攻击者可以通过构造恶意的序列化数据,在反序列化过程中执行任意代码,从而控制目标系统。比如kryo
-
防止反序列化漏洞的措施:
- 避免使用存在已知漏洞的序列化协议。
- 对序列化数据进行签名或加密,防止篡改。
- 使用白名单机制,只允许反序列化特定类型的对象。
- 限制反序列化的深度和复杂度,防止资源耗尽。
8.Unsafe解析
Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等
Unsafe 提供的这些功能的实现需要依赖本地方法(Native Method),本地方法使用 native 关键字修饰,Java 代码中只是声明方法头,具体的实现则交给 本地代码。
Unsafe 类为一单例实现,提供静态方法 getUnsafe 获取 Unsafe实例。这个看上去貌似可以用来获取 Unsafe 实例。但是,当我们直接调用这个静态方法的时候,会抛出 SecurityException 异常
这是因为在getUnsafe方法中,会对调用者的classLoader进行检查,判断当前类是否由Bootstrap classLoader加载,如果不是的话那么就会抛出一个SecurityException异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。
为什么这个类这么严格?Unsafe 提供的功能过于底层(如直接访问系统内存资源、自主管理内存资源等),安全隐患也比较大,使用不当的话,很容易出现很严重的问题。
那我们该怎么去获取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 命令行命令-Xbootclasspath/a把调用 Unsafe 相关方法的类 A 所在 jar 包路径追加到默认的 bootstrap 路径中,使得 A 被引导类加载器加载,从而通过Unsafe.getUnsafe方法安全的获取 Unsafe 实例。
Unsafe的功能多种多样,比如内存操作,内存屏障,对象操作,数据操作,CAS 操作,线程调度,Class 操作,系统信息
- 内存操作:
在 Java 中是不允许直接对内存进行操作的,对象内存的分配和回收都是由 JVM 自己实现的。但是在 Unsafe 中,提供的下列接口可以直接进行内存操作:
//分配新的本地空间
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);
通过这种方式分配的内存属于 堆外内存 ,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在try中执行对内存的操作,最终在finally块中进行内存的释放。
那我们为什么要使用堆外内存?
对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。
提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。
比如:DirectByteBuffer 是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。DirectByteBuffer 对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。类似实现了零拷贝的功能,但是其实他并没有实现零拷贝。创建 DirectByteBuffer 的时候,通过 Unsafe.allocateMemory 分配内存、Unsafe.setMemory 进行内存初始化,而后构建 Cleaner 对象用于跟踪 DirectByteBuffer 对象的垃圾回收,以实现当 DirectByteBuffer 被垃圾回收时,分配的堆外内存一起被释放
- 内存屏障:
编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。
比如我们的voliate关键词就是通过内存屏障,来保证了禁止重排。主要是就是保证读写的屏障
内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在 Java8 中,引入了 3 个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 JVM 来生成内存屏障指令,来实现内存屏障的功能
Unsafe 中提供了下面三个内存屏障相关方法:
//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前
public native void loadFence();
//内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前
public native void storeFence();
//内存屏障,禁止load、store操作重排序
public native void fullFence();
内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。
应用:
StampedLock 提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于 StampedLock 提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存 load 到线程工作内存时,会存在数据不一致问题。
为了解决这个问题,StampedLock 的 validate 方法会通过 Unsafe 的 loadFence 方法加入一个 load 内存屏障。