有一个普遍的误解,就是如果您不喜欢垃圾回收,那么您就不需要用Java来编写,而是要用C / C ++来编写。 在过去的三年中,我一直在编写用于货币交易的低延迟Java代码,而且我必须避免以各种方式创建不必要的对象。 结果,我为自己制定了一些简单的规则,即如何减少Java中的分配(如果不减少到零,然后减少到合理的最低限度),而无需借助手动内存管理。 也许对社区中的某人也很有用。
为什么根本避免垃圾
关于GC是什么以及如何配置它们,已经有很多说法和文章了。 但是最终,无论您如何设置GC,乱抛垃圾的代码都无法达到最佳效果。 在吞吐量和延迟之间始终要权衡取舍。 要改善一个而不恶化另一个就变得不可能。 通常,GC开销是通过研究日志来衡量的-您可以从中了解暂停的时间和花费的时间。 但是,GC日志不包含有关此开销的所有信息。 线程创建的对象将自动放置在运行线程的处理器核心的L1高速缓存中。 这导致其他潜在有用数据被挤出。 通过大量分配,有用的数据也可以从L3缓存中推出。 线程下次访问该数据时,将发生未命中高速缓存,这将导致程序执行的延迟。 此外,由于L3高速缓存对于同一处理器中的所有内核都是通用的,因此垃圾流将推送L3高速缓存中的数据和其他线程/应用程序,即使它们是用裸C编写的,它们也已经遇到了额外的高速缓存未命中。并且不要创建垃圾。 没有设置,没有垃圾收集器(C4或ZGC都没有)将有助于解决此问题。 改善整体情况的唯一方法是不要不必要地创建不必要的对象。 Java与C ++不同,Java没有丰富的内存处理机制,但是,有许多方法可以最小化分配。 他们将被讨论。
抒情离题当然,您不需要编写所有无垃圾代码。 关于Java语言的事情是,您可以通过仅删除主要垃圾源来极大地简化生活。 编写无锁算法时,您也不能处理安全的内存回收。 如果某些代码在应用程序启动时仅执行一次,那么它可以分配任意数量的代码,这没什么大不了的。 好吧,当然,消除多余垃圾的主要工作工具是分配探查器。
使用原始类型
在许多情况下,最简单的事情是使用基本类型而不是对象类型。 JVM进行了许多优化,以最大程度地减少对象类型的开销,例如,缓存较小的整数类型值和内联简单类。 但是这些优化并不总是值得依赖的,因为它们可能无法解决:整数值可能不会被缓存,并且内联可能不会发生。 此外,在使用条件整数时,我们被迫遵循该链接,这有可能导致高速缓存未命中。 同样,所有对象都有标头,这些标头会占用缓存中的额外空间,从而从那里挤出其他数据。 让我们来看一下:原始int占用4个字节。 对象Integer
占用16个字节+与此整数的链接的大小最小为4个字节(对于压缩的oop)。 总的来说,与int
相比, Integer
占用的空间多五(!) int
。 因此,最好自己使用基本类型。 我会举几个例子。
例子1.常规计算
假设我们有一个常规函数,它只是在计算一些东西。
Integer getValue(Integer a, Integer b, Integer c) { return (a + b) / c; }
这样的代码很可能会内联(方法和类),并且不会导致不必要的分配,但是您不能确定这一点。 即使发生这种情况, NullPointerException
可能会飞出此处也存在问题。 JVM要么不得不在幕后插入null
检查,要么从上下文中以某种方式理解到null
不能作为参数。 无论如何,最好只在基元上编写相同的代码。
int getValue(int a, int b, int c) { return (a + b) / c; }
例子2. Lambdas
有时对象是在我们不知情的情况下创建的。 例如,如果我们将基本类型传递到期望的对象类型。 使用lambda表达式时,通常会发生这种情况。
假设我们有以下代码:
void calculate(Consumer<Integer> calculator) { int x = System.currentTimeMillis(); calculator.accept(x); }
尽管变量x是原始变量,但仍将创建Integer类型的对象,该对象将传递给计算器。 为了避免这种情况,请使用IntConsumer
而不是Consumer<Integer>
:
void calculate(IntConsumer calculator) { int x = System.currentTimeMillis(); calculator.accept(x); }
这样的代码将不再导致创建额外的对象。 Java.util.function具有一整套适用于使用原始类型的标准接口: DoubleSupplier
, LongFunction
等。 好吧,如果缺少某些东西,那么您总是可以使用基本元素添加所需的接口。 例如,可以使用自制界面来代替BiConsumer<Integer, Double>
。
interface IntDoubleConsumer { void accept(int x, double y); }
例子3.集合
使用基本类型可能很困难,因为此类型的变量在集合中。 假设我们有一些List<Integer>
并且我们想找出其中的数字并计算每个数字重复多少次。 为此,我们使用HashMap<Integer, Integer>
。 代码如下:
List<Integer> numbers = new ArrayList<>();
这段代码在几种方面都是不好的。 首先,它使用中间数据结构,这可能不需要它。 好吧,为简单起见,我们假设以后会需要此列表。 您无法将其完全删除。 其次,在两个地方Integer
使用对象Integer
而不是原始int
。 第三, compute
方法中有很多分配。 第四,分配迭代器。 但是,此分配可能会变成内联。 如何将此代码转换为无垃圾代码? 您只需要对某些第三方库中的原语使用集合。 有许多包含此类集合的库。 以下代码使用了agrona库。
IntArrayList numbers = new IntArrayList();
此处创建的对象是两个集合和两个int[]
,它们位于这些集合内。 通过对它们调用clear()
方法,可以重用这两个集合。 使用基元上的集合,我们没有使代码复杂化(甚至通过删除其中包含复杂lambda的计算方法来简化了代码),并且与使用标准集合相比,还获得了以下额外好处:
- 几乎完全没有分配。 如果集合被重用,那么将根本没有分配。
- 节省大量内存(
IntArrayList
占用的空间比ArrayList<Integer>
少大约五倍。如上所述,我们关心的是处理器高速缓存(而不是RAM)的经济使用。 - 串行访问内存。 关于为何如此重要的话题已经写了很多,所以我不会就此止步。 以下是几篇文章: Martin Thompson和Ulrich Drepper 。
关于收藏的另一个小评论。 可能会发现该集合包含不同类型的值,因此不可能用具有原语的集合替换它。 我认为,这表明数据结构或整个算法的设计不佳。 在这种情况下,最有可能不是主要问题。
可变对象
但是,如果不能放弃原语怎么办? 例如,如果我们需要的方法应该返回几个值。 答案很简单-使用可变对象。
小题外话有些语言强调使用不可变对象,例如在Scala中。 支持它们的主要论据是,大大简化了编写多线程代码的过程。 但是,还存在与垃圾过多分配相关的开销。 如果我们想避免它们,那么我们不应该创建短暂的不可变对象。
在实践中看起来像什么? 假设我们需要计算商和除法的余数。 为此,我们使用以下代码。
class IntPair { int x; int y; } IntPair divide(int value, int divisor) { IntPair result = new IntPair(); result.x = value / divisor; result.y = value % divisor; return result; }
在这种情况下,如何摆脱分配? 没错,将IntPair
作为参数传递并在其中写入结果。 在这种情况下,您需要编写详细的javadoc,甚至更好的是,对变量名使用某种约定,并在其中写入结果。 例如,它们可以以前缀out开头。 在这种情况下,免费垃圾代码将如下所示:
void divide(int value, int divisor, IntPair outResult) { outResult.x = value / divisor; outResult.y = value % divisor; }
我想指出, divide
不应该将链接保存到任何地方配对或将其传递给可以做到这一点的方法,否则我们可能会遇到很大的问题。 如我们所见,可变对象比基本类型更难使用,因此,如果可以使用基本类型,则更好。 实际上,在我们的示例中,我们将分配问题从除法内部转移到外部。 在调用此方法的所有地方,我们都需要有一些IntPair
虚拟对象,并将其传递给divide
。 通常足以将这个虚拟对象存储在对象的final
字段中,在此我们称为divide
。 让我给您举一个牵强的示例:假设我们的程序仅处理通过网络接收数字流,将它们相除,然后将结果发送到同一套接字。
class SocketListener { private final IntPair pair = new IntPair(); private final BufferedReader in; private final PrintWriter out; SocketListener(final Socket socket) throws IOException { in = new BufferedReader(new InputStreamReader(socket.getInputStream())); out = new PrintWriter(socket.getOutputStream(), true); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); divide(value, divisor, pair); out.print(pair.x); out.print(pair.y); } } }
为简洁起见,我没有编写用于处理错误,正确终止程序等的“额外”代码。 这段代码的主要思想是,我们IntPair
的IntPair
对象创建一次并存储在final
字段中。
对象池
当我们使用可变对象时,我们必须首先从某个地方取出一个空对象,然后将所需的数据写入其中,在某个地方使用它,然后“就地”返回该对象。 在上面的示例中,对象始终处于“适当位置”,即 在final
领域。 不幸的是,这并非总是能够以简单的方式做到的。 例如,我们可能事先不确切知道我们需要多少个对象。 在这种情况下,对象池将为我们提供帮助。 当我们需要一个空对象时,我们从对象池中获取它,而当不再需要它时,我们将其返回。 如果池中没有可用的对象,则池将创建一个新对象。 实际上,这是一种手动内存管理,具有所有后续后果。 如果可以使用以前的方法,建议不要诉诸此方法。 可能出什么问题了?
- 我们可以忘记将对象返回到池中,然后将创建垃圾(“内存泄漏”)。 这是一个小问题-性能会略有下降,但是GC将正常运行,并且程序将继续运行。
- 我们可以将对象返回到池中,但是将其链接保存到某个位置。 然后其他人将从池中获取该对象,此时,在我们的程序中,已经有两个指向同一对象的链接。 这是一个经典的售后使用问题。 很难登场,因为 与C ++不同,该程序不会崩溃,并且将继续无法正常工作。
为了减少发生上述错误的可能性,您可以使用标准的try-with-resources构造。 它可能看起来像这样:
public interface Storage<T> { T get(); void dispose(T object); } class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} public static IntPair create() { return STORAGE.get(); } @Override public void close() { STORAGE.dispose(this); } }
除法可能如下所示:
IntPair divide(int value, int divisor) { IntPair result = IntPair.create(); result.x = value / divisor; result.y = value % divisor; return result; }
和listenSocket
方法listenSocket
这样的:
void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); try (IntPair pair = divide(value, divisor)) { out.print(pair.x); out.print(pair.y); } } }
在IDE中,当在try-with-resources块之外使用AutoCloseable
对象时,通常可以配置所有情况的突出显示。 但这不是绝对的选择,因为 可以关闭IDE中的高亮显示。 因此,还有另一种保证对象返回池的方法-控制反转。 我举一个例子:
class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} private static void apply(Consumer<IntPair> consumer) { try(IntPair pair = STORAGE.get()) { consumer.accept(pair); } } @Override public void close() { STORAGE.dispose(this); } }
在这种情况下,我们基本上无法IntPair
外部访问IntPair
类的对象。 不幸的是,这种方法也不总是有效。 例如,如果一个线程从池中获取对象并将其放入队列中,而另一个线程将其从队列中移出并返回池中,则它将不起作用。
显然,如果我们不在池中存储通用对象,但是某些库对象未实现AutoCloseable
,那么try-with-resources选项也将不起作用。
这里的另一个问题是多线程。 对象池的实现必须非常快,这很难实现。 慢速缓冲池对性能的危害大于弊。 反过来,TLAB中新对象的分配非常快,比C中的malloc快得多。编写快速对象池是我现在不想开发的独立主题。 我只能说我还没有看到任何好的“现成”实现。
而不是结论
简而言之,在对象池中重复使用对象是严重的痔疮。 幸运的是,几乎总是可以没有它。 我的个人经验是,过度使用对象池表示应用程序体系结构存在问题。 通常,对我们而言,在final
字段中缓存的对象的一个实例就足够了。 但是,如果可以使用原始类型,那么即使这也太过分了。
更新:
是的,对于那些不怕按位转换的人,我还记得另一种方法:将几种小的原始类型打包为一个大的原始类型。 假设我们需要返回两个int
。 在这种特殊情况下,您不能使用IntPair
对象,而是返回一个long
,前四个字节将与第一个IntPair
相对应,第二个4个字节与第二个IntPair
相对应。 代码可能看起来像这样:
long combine(int left, int right) { return ((long)left << Integer.SIZE) | (long)right & 0xFFFFFFFFL; } int getLeft(long value) { return (int)(value >>> Integer.SIZE); } int getRight(long value) { return (int)value; } long divide(int value, int divisor) { int x = value / divisor; int y = value % divisor; return combine(left, right); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); long xy = divide(value, divisor); out.print(getLeft(xy)); out.print(getRight(xy)); } }
当然,需要对这些方法进行彻底的测试,因为将它们写下来非常容易。 但是然后就使用它。