Java直接内存
NIO的Buffer提供了一个可以不经过JVM内存直接访问系统物理内存的类——DirectBuffer。 DirectBuffer类继承自ByteBuffer,但和普通的ByteBuffer不同,普通的ByteBuffer仍在JVM堆上分配内存,其最大内存受到最大堆内存的限制;而DirectBuffer直接分配在物理内存中,并不占用堆空间,其可申请的最大内存受操作系统限制。
直接内存的读写操作比普通Buffer快,但它的创建、销毁比普通Buffer慢。
因此直接内存使用于需要大内存空间且频繁访问的场合,不适用于频繁申请释放内存的场合。
DirectBuffer并没有真正向OS申请分配内存,其最终还是通过调用Unsafe的allocateMemory()来进行内存分配。不过JVM对Direct Memory可申请的大小也有限制,可用-XX:MaxDirectMemorySize=1M设置,这部分内存不受JVM垃圾回收管理。
class DirectMemory {
// 分配堆内存
public static void bufferAccess() {
long startTime = System.currentTimeMillis();
ByteBuffer b = ByteBuffer.allocate(500);
for (int i = 0; i < 1000000; i++) {
for (int j = 0; j < 99; j++)
b.putInt(j);
b.flip();
for (int j = 0; j < 99; j++)
b.getInt();
b.clear();
}
long endTime = System.currentTimeMillis();
System.out.println("access_nondirect:" + (endTime - startTime));
}
// 直接分配内存
public static void directAccess() {
long startTime = System.currentTimeMillis();
ByteBuffer b = ByteBuffer.allocateDirect(500);
for (int i = 0; i < 1000000; i++) {
for (int j = 0; j < 99; j++)
b.putInt(j);
b.flip();
for (int j = 0; j < 99; j++)
b.getInt();
b.clear();
}
long endTime = System.currentTimeMillis();
System.out.println("access_direct:" + (endTime - startTime));
}
public static void bufferAllocate() {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
ByteBuffer.allocate(1000);
}
long endTime = System.currentTimeMillis();
System.out.println("allocate_nondirect:" + (endTime - startTime));
}
public static void directAllocate() {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
ByteBuffer.allocateDirect(1000);
}
long endTime = System.currentTimeMillis();
System.out.println("allocate_direct:" + (endTime - startTime));
}
public static void main(String args[]) {
System.out.println("访问性能测试:");
bufferAccess();
directAccess();
System.out.println();
System.out.println("分配性能测试:");
bufferAllocate();
directAllocate();
}
}
输出:
访问性能测试:
access_nondirect:157
access_direct:134
分配性能测试:
allocate_nondirect:231
allocate_direct:613
可见与在JVM堆分配内存(allocate)相比,直接内存分配(allocateDirect)的访问性能更好,但分配较慢。(一般如此,当然数据量小的话差别不是那么明显)
方法区和运行时常量池
方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据(是唯一的数据)。当java虚拟机通过类加载器加载这个类的时候,这个类的信息就会保存到方法区中,虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
HotSpot虚拟机处理方法区的时候选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。可是其他的虚拟机并不会这样处理。
总结:
- 常量池(Constant Pool):常量池数据编译期被确定,是Class文件中的一部分。存储了类、方法、接口等中的常量,当然也包括字符串常量。
- 字符串池/字符串常量池(String Pool/String Constant Pool):是常量池中的一部分,存储编译期类中产生的字符串类型数据。
- 运行时常量池(Runtime Constant Pool):方法区的一部分,所有线程共享。虚拟机加载Class后把常量池中的数据放入到运行时常量池。
- 即常量池在Class文件中,运行时常量池在JVM中。
- JDK1.7之前字符串常量池位于方法区之中。 JDK1.7字符串常量池已经被挪到堆之中。
减少上下文切换
(1)避免竞争:比如使用哈希算法对数据进行分段,每个线程处理自己负责的数据段,从而避免了竞争。
(2)用CAS算法避免使用锁:比如使用Java的Atomic包。
(3)乐观锁:大多是基于数据版本( Version )记录机制实现。一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
(4)开启适当数量的线程,避免大量线程处于等待和竞争状态。
volatile的原理
volatile保证可见性和原子性:
- 可见性:当一个线程修改了一个共享变量时,其他线程可以读到修改后的数据。
- 原子性:对任意单个volatile变量的读/写操作具有原子性,但类型volatile++这样的操作不具有原子性。
原理是基于汇编指令实现,当对一个volatile变量进行写操作的时候,编译器会多出一条LOCK前缀指令,它会引发两件事情:
(1)将当前处理器的缓存行里的数据写回到系统内存;
(2)这个写回操作会使其他CPU里缓存对这个数据的缓存无效;所以当其他线程再次需要使用这个数据的时候,它会再从内存中读取,而不是使用缓存。
synchronized锁优化
基本原理
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
synchronized锁存在对象头里的Mark Word:
- 偏向锁:记录拥有锁的线程ID;
- 轻量锁:指向栈中锁记录的指针;
- 重量锁:指向互斥量的指针;
锁升级:只能升,不能降。
偏向锁
同步代码块在大多数时候没有竞争,总是由一个线程多次获得,这时获取锁只需要判断一下Mark Word中存的线程ID是不是自己,如果是就获取了锁。
偏向锁进行线程间切换的过程一定是无竞争的,线程2发现Mark Word中存的线程ID是线程1的则使用CAS操作修改Mark Word,如果成功则获取到了锁,如果失败则说明存在竞争,锁升级为轻量锁。
轻量锁
核心理念是当发生竞争的时候,用自旋代替阻塞,阻塞就涉及内核态到用户态的切换,耗时。
当一个线程自旋获取锁失败了,它会将这个锁升级为重量锁,这个时候获取不到锁的线程会被阻塞。
原子操作的原理
总线锁
使用CPU提供的LOCK#信号,当一个CPU在总线上输出此信号的时候,其他CPU的请求被阻塞住。
弊端是总线锁阻塞了所有CPU和内存之间的通信。
缓存一致性机制
整体来说,是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取。感兴趣的可以了解一下MESI协议。
MESI协议
MESI 协议是以缓存行(缓存的基本数据单位,在Intel的CPU上一般是64字节)的几个状态来命名的(全名是Modified、Exclusive、 Share or Invalid)。该协议要求在每个缓存行上维护两个状态位,使得每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:
- M:被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。
- E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。
- S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。
- I:无效的。本CPU中的这份缓存已经无效。
这里首先介绍该协议约定的缓存上对应的监听:
- 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
- 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
- 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
- 当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
- 当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。
Java实现原子性
(1)循环CAS,比如Atomic包;
(2)使用锁。锁的内存语义是线程A在释放锁之前保有的所有共享变量,在线程B获取锁后,立刻变得对线程B可见,也就是刷新回主内存了。
CAS实现原子操作的问题
(1)ABA问题
ABA:如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化。带来的影响我们可以举个例子:
小明在提款机,提取了50元,因为提款机问题,
有两个线程,同时把余额从100变为50
线程1(提款机):获取当前值100,期望更新为50,
线程2(提款机):获取当前值100,期望更新为50,
线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)
这就是ABA问题带来的成功提交
(2)循环的时间过长则开销很大
(3)只能保证一个共享变量的原子操作
双重检查锁定与延迟初始化
没有考虑并发的写法:
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if (instance == null) //1:A线程执行
instance = new Instance(); //2:B线程执行
return instance;
}
}
同步处理带来的性能低下:
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null)
instance = new Instance();
return instance;
}
}
双重检查锁定:
public class DoubleCheckedLocking { //1
private static Instance instance; //2
public static Instance getInstance() { //3
if (instance == null) { //4:第一次检查
synchronized (DoubleCheckedLocking.class) { //5:加锁
if (instance == null) //6:第二次检查
instance = new Instance(); //7:问题的根源出在这里
} //8
} //9
return instance; //10
} //11
} //12
问题的根源:指令重排
前面的双重检查锁定示例代码的第7行(instance = new Singleton();)创建一个对象。这一行代码可以分解为如下的三行伪代码:
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
上面三行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器上,这种重排序是真实发生的,详情见参考文献1的“Out-of-order writes”部分)。2和3之间重排序之后的执行时序如下:
memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址
//注意,此时对象还没有被初始化!
ctorInstance(memory); //2:初始化对象
基于volatile的双重检查锁定的解决方案
对于前面的基于双重检查锁定来实现延迟初始化的方案(指DoubleCheckedLocking示例代码),我们只需要做一点小的修改(把instance声明为volatile型),就可以实现线程安全的延迟初始化。请看下面的示例代码:
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance();//instance为volatile,现在没问题了
}
}
return instance;
}
}
注意,这个解决方案需要JDK5或更高版本(因为从JDK5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义)。
当声明对象的引用为volatile后,“问题的根源”的三行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。
基于类初始化的解决方案
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance ; //这里将导致InstanceHolder类被初始化
}
}