В первой части мы увидели, что объекты в целом используют гораздо больше памяти, чем их примитивные аналоги. А именно, накладные расходы более чем в 4 раза превышают размер полезной нагрузки (полезная информация, представленная как примитивный тип данных).
Есть еще две проблемы, связанные с памятью, а именно размер наиболее часто используемой коллекции и их стратегия изменения размера. Оба эти вопроса имеют практическое применение и заслуживают внимания.
Объем памяти наиболее часто используемых коллекций
Давайте посмотрим на класс Java LinekdList. Сколько памяти использует объект LinkedList?
LinkedList<Integer> l = new LinkedList<Integer>();
Не вдаваясь в подробности, как в первой части, объект LinkedList будет выглядеть примерно так, как показано на рисунке ниже:

С точки зрения объема памяти, каждому объекту LinkedList потребуется память для хранения полей, унаследованных от класса Object, как и для всех других объектов. Кроме того, память также будет выделена для поля int, в котором хранится размер списка и ссылка на первый и последний элементы.
Кроме того, для каждого значения, содержащегося в LinkedList, обратите внимание, что существует потребность во внутреннем объекте (а именно, узле), который содержит полезную информацию о полезной нагрузке и ссылки на предыдущие и следующие записи списка.
Вот код для класса Node:
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
Подводя итог, в случае LinkedList, поверх фактически полезной памяти (которая хранит элементы в списке) будет выделено множество других объектов. Эти внутренние объекты необходимы для работы LinkedList.
На изображении выше мы видим оценку полезной памяти по сравнению с коэффициентом служебной памяти. Это соотношение составляет примерно 1: 1, поэтому чем больше LinkedList, тем линейно больше накладные расходы.
Гораздо более простой класс - это класс ArrayList:
ArrayList<Integer> l = new ArrayList<Integer>(50);
Если мы рассмотрим внутреннюю работу ArrayList, нам понадобится память для хранения полей, унаследованных от объекта, еще один 32-битный int для хранения размера ArrayList и ссылка на массив целых чисел. Следующее изображение является упрощением объема памяти, занимаемого объектом ArrayList:

Этот класс гораздо более щедр в том, что касается его полезного использования памяти, а не накладных расходов. Объем памяти, используемый его накладными расходами, является постоянным, в то время как чем больше элементов мы добавляем к нему, тем увеличивается объем памяти для фактических полезных данных.
Похожий случай - класс StringBuffer:

Здесь у нас также есть постоянная память для служебных полей (унаследованная от Object, ссылка на char []), в то время как полезная память полезной нагрузки увеличивается линейно.
И последнее, но не менее важное, давайте взглянем на класс HashMap.
HashMap<String, Integer> map = new HashMap<String, Integer>();
Объекту HashMap потребуется память для полей, которые он наследует от класса Object, еще одно значение с плавающей запятой для loadFactor, int для treshold, int для размера и т. Д. Вдобавок к этому и массив, в котором хранятся сегменты, и для каждое ведро - это список, содержащий его записи. Примерно это показано на изображении ниже:

Хотя мы не хотим вдаваться в подробности того, как работает HashMap, обратите внимание, что для каждой записи значения ключа будет использоваться некоторая служебная память, а именно. Просто для мелочи, это код класса Node, который реализует Map.Entry, используемый HashMap:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
Подводя итог использованию памяти HashMap, ясно, что накладные расходы линейно масштабируются с полезной нагрузкой, а именно: чем больше записей у нас есть, тем больше служебной памяти мы используем в соотношении примерно 2: 1.
Проблема с изменением размера коллекций
Что происходит с памятью в следующих строках кода?
StringBuffer s = new StringBuffer(); s.append(“MY STRING”); s.append(“ OF TEXT”);
В первой строке создается новый объект StringBuffer. Во второй строке к ней добавляется строка «МОЯ СТРОКА». Примерный объем памяти показан на изображении ниже:

Третья строка немного сложнее: поскольку емкость StringBuffer по умолчанию составляет 16 символов, у нашего объекта было достаточно места, чтобы добавить «МОЯ СТРОКА», но явно недостаточно места, чтобы также добавить строку «OF TEXT».
Что произойдет, так это то, что внутренний массив char объекта StringBuffer будет изменен, а именно его размер удвоится от наличия места для хранения 16 символов до наличия достаточного места для хранения 32 символов. Наш объект StringBuffer после третьей строки будет выглядеть примерно так, как показано на рисунке ниже:

По умолчанию (начальная) емкость класса StringBuffer равна 16. Поведение класса StringBuffer при изменении размера заключается в том, что он удваивает свою емкость каждый раз, когда заканчивается пространство.
В таблице ниже мы видим емкость по умолчанию и коэффициент изменения размера для некоторых из наиболее часто используемых классов Java:

Такое поведение определенных классов, если его не отмечать, приведет к следующей ситуации: большая коллекция почти полностью заполнена и в нее добавляется пара новых элементов. Это приведет к удвоению размера коллекции, и, хотя первая половина ее памяти будет заполнена полезными элементами, хранящимися там, вторая часть будет использовать пространство, но не будет хранить почти любую полезную нагрузку.
StringBuffer s = new StringBuffer(); s.append(“MY STRING”); s.append(“ OF TEXT”);
Например, после трех строк кода выше объект StringBuffer будет использовать пространство, достаточное для хранения 32 символов, но на самом деле будет хранить только 17. Таким образом, пространство для хранения 15 символов будет потрачено впустую.
Это называется проблемой изменения размера коллекции, и в качестве решения мы можем сделать следующее: По возможности использовать конструктор, который принимает емкость в качестве параметра. Таким образом, когда мы знаем окончательную емкость хранилища, создайте экземпляр коллекции, чтобы она имела эту заданную емкость с самого начала.
Выводы и передовой опыт
- Если все остальное одинаково, используйте коллекцию, которая минимизирует накладные расходы.
- Для небольших коллекций, если проблема не в функциональности, используйте вместо них обычные массивы.
- Избегайте добавления элементов до тех пор, пока ваши коллекции не изменят размер слишком много раз.
- Насколько это возможно, оцените фактически необходимую емкость данной коллекции и создайте ее экземпляр для этой емкости с самого начала.
Если вы еще этого не сделали, ознакомьтесь с первой частью этой статьи: Объем памяти Java - часть 1