如果您曾经分配过大的Java堆,您就会知道在某个时候(通常从大约4 GiB开始),您将开始遇到垃圾回收暂停的问题。
我不会详细介绍为什么在JVM中会出现暂停,但是总之,当JVM进行完整的收集并且您有很大的堆时,就会发生暂停。 随着堆的增加,这些集合可能会变得更长。
解决此问题的最简单方法是调整JVM垃圾回收参数,以匹配特定应用程序的内存分配和释放行为。 这有点晦涩,需要仔细测量,但是可能有很大的堆,同时又避免了大多数旧式垃圾回收。 如果您想了解有关垃圾收集调优的更多信息,请查阅JVM GC调优指南 。 如果您总体上对GC真的很感兴趣,那么这是一本很棒的书: The Garbage Collection Handbook 。
有一些JVM实现可以保证比Sun VM(例如Zing JVM)要少得多的暂停时间,但是通常会增加系统的其他成本,例如增加内存使用量和单线程性能。 易于配置和低gc保证仍然非常吸引人。 出于本文的目的,我将使用内存缓存或Java存储示例,这主要是因为我在过去使用这些技术中的一部分时已经构建了一对。
我们假设我们有一个基本的缓存接口定义,如下所示:
import java.io.Externalizable;
public interface Cache<K extends Externalizable, V extends Externalizable> {
public void put(K key, V value);
public V get(K key);
}
对于这个简单的示例,我们要求键和值是可外部化的,而不是像此IRL那样。
我们将展示如何使用此缓存的不同实现,以不同的方式将数据存储在内存中。 实现此缓存的最简单方法是使用Java集合:
import java.io.Externalizable;
import java.util.HashMap;
import java.util.Map;
public class CollectionCache<K extends Externalizable, V extends Externalizable> implements Cache<K, V> {
private final Map<K, V> backingMap = new HashMap<K, V>();
public void put(K key, V value) {
backingMap.put(key, value);
}
public V get(K key) {
return backingMap.get(key);
}
}
该实现是直接的。 但是,随着地图大小的增加,我们将分配大量对象(并取消分配),我们使用的是盒装原语,它占用了更多的内存空间,因此原语和地图需要不时调整大小。 当然,我们可以简单地通过使用基于基元的映射来改进此实现。 它会使用较少的内存和对象,但仍会占用堆中的空间并可能对堆进行分区,如果由于其他原因我们执行完整的GC,则会导致更长的暂停时间。
让我们看看不使用堆来存储相似数据的其他方法:
- 使用一个单独的过程来存储数据 。 可能是通过套接字或Unix套接字连接的Redis或Memcached实例。 实施起来相当简单。
- 使用内存映射文件将数据卸载到磁盘 。 操作系统是您的朋友,并且会做很多繁重的工作来预测接下来从文件中读取的内容以及与文件的接口,就像是一大堆数据一样。
- 使用本机代码并通过JNI或JNA访问它 。 通过JNI,您将获得更好的性能,并通过JNA易于使用。 需要您编写本机代码。
- 使用 NIO包中直接分配的缓冲区 。
- 使用Sun特定的Unsafe类可以直接从Java代码访问内存。
我将重点介绍本文仅使用Java的解决方案,直接分配的缓冲区和Unsafe类。
直接分配的缓冲区
在Java NIO中开发高性能网络应用程序时,直接分配缓冲区非常有用,并且广泛使用。 通过在堆外直接分配数据,在许多情况下,您可以编写软件,使这些数据实际上永远不会碰到堆。
创建新的直接分配缓冲区非常简单:
int numBytes = 1000;
ByteBuffer buffer = ByteBuffer.allocateDirect(numBytes);
创建新缓冲区后,可以用几种不同的方式来操作缓冲区。 如果您从未使用过Java NIO缓冲区,那么绝对值得一看,因为它们确实很棒。
除了填充,清空和标记缓冲区中不同点的方法外,您还可以选择在缓冲区上使用不同的视图而不是ByteBuffer –例如, buffer.asLongBuffer()
为您提供了在ByteBuffer上的视图,您可以在该视图上buffer.asLongBuffer()
操作元素。
那么如何在我们的Cache示例中使用它们? 有很多种方法,最直接的方法是将值记录的序列化/外部化形式存储在一个大数组中,以及指向该数组中记录的偏移量和大小的键映射。
可能看起来像这样(非常宽松的方法,缺少实现并假设记录大小固定):
import java.io.Externalizable;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
public class DirectAllocatedCache<K extends Externalizable, V extends Externalizable> implements Cache<K,V> {
private final ByteBuffer backingMap;
private final Map<K, Integer> keyToOffset;
private final int recordSize;
public DirectAllocatedCache(int recordSize, int maxRecords) {
this.recordSize = recordSize;
this.backingMap = ByteBuffer.allocateDirect(recordSize * maxRecords);
this.keyToOffset = new HashMap<K, Integer>();
}
public void put(K key, V value) {
if(backingMap.position() + recordSize < backingMap.capacity()) {
keyToOffset.put(key, backingMap.position());
store(value);
}
}
public V get(K key) {
int offset = keyToOffset.get(key);
if(offset >= 0)
return retrieve(offset);
throw new KeyNotFoundException();
}
public V retrieve(int offset) {
byte[] record = new byte[recordSize];
int oldPosition = backingMap.position();
backingMap.position(offset);
backingMap.get(record);
backingMap.position(oldPosition);
//implementation left as an exercise
return internalize(record);
}
public void store(V value) {
byte[] record = externalize(value);
backingMap.put(record);
}
}
如您所见,此代码有许多限制:固定的记录大小,固定的支持映射大小,完成外部化的方式有限,难以删除和重用空间等。尽管其中某些方式可以通过巧妙的方法来克服以字节数组表示记录(也可以在直接分配的缓冲区中表示keyToOffset映射)或处理删除操作(我们可以实现自己的SLAB分配器),其他诸如调整支持映射大小的操作很难克服。 一个有趣的改进是将记录实现为记录和字段的偏移量,从而减少了我们仅按需复制和复制的数据量。
请注意,JVM对直接分配的缓冲区使用的内存量施加了限制。 您可以使用-XX:MaxDirectMemorySize选项进行调整。 查看ByteBuffer javadocs
不安全
直接从Java管理内存的另一种方法是使用隐藏的Unsafe类。 从技术上讲,我们不应该使用它,它是特定于实现的,因为它位于sun软件包中,但是提供的可能性是无限的。
Unsafe给我们带来的是直接从Java代码分配,取消分配和管理内存的能力。 我们还可以获取实际的指针,并将它们在本机代码和Java代码之间互换传递。
为了获得一个不安全的实例,我们需要走一些弯路:
private Unsafe getUnsafeBackingMap() {
try {
Field f = Unsafe.class.getDeclaredField('theUnsafe');
f.setAccessible(true);
return (Unsafe) f.get(null);
} catch (Exception e) { }
return null;
}
一旦有了不安全因素,我们可以将其应用于之前的Cache示例:
import java.io.Externalizable;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
import sun.misc.Unsafe;
public class UnsafeCache<K extends Externalizable, V extends Externalizable> implements Cache<K, V> {
private final int recordSize;
private final Unsafe backingMap;
private final Map<K, Integer> keyToOffset;
private long address;
private int capacity;
private int currentOffset;
public UnsafeCache(int recordSize, int maxRecords) {
this.recordSize = recordSize;
this.backingMap = getUnsafeBackingMap();
this.capacity = recordSize * maxRecords;
this.address = backingMap.allocateMemory(capacity);
this.keyToOffset = new HashMap<K, Integer>();
}
public void put(K key, V value) {
if(currentOffset + recordSize < capacity) {
store(currentOffset, value);
keyToOffset.put(key, currentOffset);
currentOffset += recordSize;
}
}
public V get(K key) {
int offset = keyToOffset.get(key);
if(offset >= 0)
return retrieve(offset);
throw new KeyNotFoundException();
}
public V retrieve(int offset) {
byte[] record = new byte[recordSize];
//Inefficient
for(int i=0; i<record.length; i++) {
record[i] = backingMap.getByte(address + offset + i);
}
//implementation left as an exercise
return internalize(record);
}
public void store(int offset, V value) {
byte[] record = externalize(value);
//Inefficient
for(int i=0; i<record.length; i++) {
backingMap.putByte(address + offset + i, record[i]);
}
}
private Unsafe getUnsafeBackingMap() {
try {
Field f = Unsafe.class.getDeclaredField('theUnsafe');
f.setAccessible(true);
return (Unsafe) f.get(null);
} catch (Exception e) { }
return null;
}
}
有很多改进的空间,您需要手动执行许多操作,但是功能非常强大。 您还可以显式释放和重新分配以这种方式分配的内存,这使您可以以与C相同的方式编写一些代码。
结论
有许多种方法可以避免在Java中使用堆,并以此方式使用更多的内存。 您无需执行此操作,而且我个人看到运行20GiB-30GiB且已进行了适当调整的JVM,并且没有长时间的垃圾收集暂停,但这非常有趣。
如果要查看一些项目如何将其用于我在此处编写的基本(并且未经测试,几乎写在餐巾纸上)缓存代码,请查看EHCache的BigMemory或Apache Cassandra,它们也将Unsafe用于此类方法。
参考:在Java Advent Calendar博客上,我们的JCG合作伙伴 Ruben Badaro 从JVM堆转出了内存密集型应用程序 。
翻译自: https://www.javacodegeeks.com/2012/12/escaping-the-jvm-heap-for-memory-intensive-applications.html