В этой статье мы рассмотрим такую функцию в Java как автоупаковка/распаковка. Автоупаковка (autoboxing) это автоматическое преобразование примитивного типа в объект класса-обертки (
Буквы в третьем столбце это обозначения типов в байт-коде:
Изменена предыдущая программа с функцией "автоматическая распаковка", но по-прежнему необходимо избегать появления NullPointerException.
int в Integer), а распаковка (unboxing) обратное преобразование объекта в примитив. Обе операции подставляет компилятор: упаковку вызовом Integer.valueOf(), распаковку вызовом intValue(). Механизм появился в Java 5.Кратко
- Автоупаковка это автоматическое превращение примитива в объект класса-обертки, распаковка обратное превращение. Обе операции подставляет компилятор, в исходном коде их не видно.
- Никакой магии: упаковка компилируется в вызов
Integer.valueOf(), распаковка в вызовintValue(). Это видно в байт-коде черезjavap -c. - Пар «примитив плюс обертка» ровно восемь, от
booleanиBooleanдоdoubleиDouble. У массивов примитивов оберток нет. valueOf()отдает готовые объекты из кэша для значений от -128 до 127, поэтому сравнение оберток через==для маленьких чисел даетtrue, а для большихfalse. Сравнивать нужно черезequals().- Упаковка в цикле создает миллионы лишних объектов: в примере ниже замена
Longнаlongускорила программу примерно в 10 раз. - Распаковка
nullзаканчиваетсяNullPointerException, а смешивание классов, скомпилированных на разных версиях Java, даетNoSuchMethodError.
Вступление
Язык программирования, как и язык на котором говорят люди, живет и меняется, в нем появляются новые явления, чтобы языком было пользоваться удобнее. А как мы знаем язык должен удобно выражать наши мысли. В версиях ниже JDK 1.5 было не легко преобразовывать примитивные типы данных, такие какint, char, float, double в их классы оболочки Integer, Character, Float, Double.
Итак, в Java SE 5 был представлен механизм упаковки/распаковки (boxing/unboxing). И особенности этого средства выражений мыслей посвящен отдельный tutorial от Oracle: Autoboxing and Unboxing.Автоупаковка Autoboxing
Давайте, рассмотрим пример автоупаковки Boxing. Сначала, посмотрим, как оно работает. Воспользуемся сайтом compilejava.net и создадим класс:
public class App {
public static void main(String[] args) {
Integer portNumber = 8080;
if (args.length != 0) {
portNumber = Integer.valueOf(args[0]);
}
System.out.println("Port number is: " + portNumber);
}
}
Простенький код. Можем указать входной параметр и поменять значение порта.
Как мы видим, т.к. мы считываем значение порта из String параметров, мы получаем Integer путем получения его через Integer.valueOf. Поэтому, мы вынуждены указывать его не примитивным типом, а как объектный тип Integer.
И тут получаем с одной стороны, у нас переменная объектная, а значение по умолчанию это примитив. И оно работает. Но мы ведь не верим в магию?
Давайте заглянем «под капот», что называется.
Скачаем с compilejava.net исходный код, нажав «Download ZIP». После этого извлечем скачанный архив в каталог и перейдем в него.
Теперь выполним: javap -c -p App.class где App.class - скомпилированный class файл для вашего класса.
Мы увидим содержимое вроде такого:
$ javap -c -p App.class
Compiled from "App.java"
public class App {
public App();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: sipush 8080
3: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
6: astore_1
7: aload_0
8: arraylength
9: ifeq 19
12: aload_0
13: iconst_0
14: aaload
15: invokestatic #3 // Method java/lang/Integer.valueOf:(Ljava/lang/String;)Ljava/lang/Integer;
18: astore_1
19: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
Этот тот самый пресловутый «байткод». Но важно нам сейчас то, что мы видим. Сначала в стек выполнения метода помещается примитив 8080, а дальше выполняется Integer.valueOf. Это и есть та «магия» boxing.
А внутри магия выглядит вот так:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
То есть по сути будет взят новый Integer или будет получен Integer из кэша (кэш ничто иное как просто массив из Integer) в зависимости от величины значения числа. Внутри JDK тут по-прежнему вызывается new Integer(i), а вот в своем коде так писать уже не стоит: с Java 9 конструкторы оберток помечены как устаревшие, и javadoc прямо советует вместо new Integer(5) использовать Integer.valueOf(5) или просто автоупаковку. Подробный разбор самого класса есть в отдельной статье: руководство по классу Java Integer.
Естественно, Integer не одному так повезло. Есть целый список соотносящихся примитивных типов и их оберток (классов, представляющие примитивы в мире ООП). Таких пар ровно восемь, и список зафиксирован в спецификации языка (JLS 5.1.7):
| Примитив | Класс-обертка | Обозначение в байт-коде | Метод распаковки |
|---|---|---|---|
boolean |
java.lang.Boolean |
Z |
booleanValue() |
byte |
java.lang.Byte |
B |
byteValue() |
short |
java.lang.Short |
S |
shortValue() |
char |
java.lang.Character |
C |
charValue() |
int |
java.lang.Integer |
I |
intValue() |
long |
java.lang.Long |
J |
longValue() |
float |
java.lang.Float |
F |
floatValue() |
double |
java.lang.Double |
D |
doubleValue() |
J досталась long, потому что L занята ссылочными типами, а boolean обозначается Z. Они пригодятся в конце статьи, когда мы будем разбирать ошибку NoSuchMethodError: Sum.add(S)V.
Стоит сразу отметить, что массивы из примитивов не имеют «обертки» без подключения каких-либо сторонних библиотек. Т.е. Arrays.asList не сделает из int[] для нас List из Integer’ов. Если список все же нужен, его собирают через стрим: List<Integer> list = Arrays.stream(arr).boxed().toList();, где boxed() упаковывает каждый элемент, а toList() собирает результат (этот метод у стримов появился в Java 16, до него писали .collect(Collectors.toList())).Распаковка Unboxing
Обратный процесс к boxing называется unboxing распаковка. Рассмотрим пример распаковки:
public class App {
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("Please, enter params");
return;
}
int value = Math.abs(Integer.valueOf(args[0]));
System.out.println("Absolute value is: " + value);
}
}
Math.abs принимает только примитивы. Что же делать? У класса обертки есть на этот случай специальный метод, возвращающий примитив. Например, для Integer это метод intValue.
Если мы посмотрим в байткод, то так и есть:
AALOAD
INVOKESTATIC java/lang/Integer.valueOf (Ljava/lang/String;)Ljava/lang/Integer;
INVOKEVIRTUAL java/lang/Integer.intValue ()I
INVOKESTATIC java/lang/Math.abs (I)I
ISTORE 1
Как видно, никакой магии. Все в рамках Java. Просто оно работает «само». Для нашего удобства.Когда используется автоупаковка и распаковка?
Автоупаковка применяется компилятором Java в двух случаях (правила описаны в том же tutorial от Oracle):- Когда значение примитивного типа передается в метод в качестве параметра, а метод ожидает объект соответствующего класса-оболочки.
- Когда значение примитивного типа присваивается переменной соответствующего класса-оболочки.
- Когда объект класса-оболочки передается в качестве параметра методу, который ожидает соответствующий примитивный тип.
- Когда объект класса-оболочки присваивается переменной соответствующего примитивного типа.
Boxing/Unboxing в цикле
Итак, предположим, что у нас есть задача по вычислению арифметической прогрессии от 0 до 1 000 000 000 с шагом 1. Случай 1 Предположим, что мы написали такой код:public class Test {
public static void main(String[] args) {
Long result = 0L;
for (int i = 0; i < 1_000_000_000; i++) {
result += i;
}
System.out.println("Result:" + result);
}
}
Будет ли он выполнять поставленную задачу? Да, но будет ли он оптимальным? Разберемся.
Для начала давайте посмотрим на байт-код, который получился (его можно получить так: javap -c Test.class).
Вот в такую ужасающую конструкцию преобразился наш цикл for:
10: if_icmpge 30
13: aload_1
14: invokevirtual #4 // Method java/lang/Long.longValue:()J
17: iload_2
18: i2l
19: ladd
20: invokestatic #2 // Method java/lang/Long.valueOf:(J)Ljava/lang/Long;
23: astore_1
24: iinc 2, 1
27: goto 7
Давайте по порядку:
- 13: Помещаем переменную Long result в стек операндов
- 14: Распаковываем ссылочный Long в примитивный long
- 17: Помещаем в стек операндов переменную int i
- 18: преобразуем ее к типу long
- 19: теперь складываем long result и long i
- 20: запаковываем результат в ссылочный тип Long
- 23: и сохраняем обратно в переменную.
public class Test {
public static void main(String[] args) {
long result = 0;
for (int i = 0; i < 1_000_000_000; i++) {
result += i;
}
System.out.println("Result:" + result);
}
}
Посмотрим, что получилось. Байт-кода стало меньше.
7: if_icmpge 21
10: lload_1
11: iload_3
12: i2l
13: ladd
14: lstore_1
15: iinc 3, 1
18: goto 4
- 10: Помещаем в стек операндов нашу переменную result
- 11: Помещаем в стек операндов переменную int i
- 12: преобразуем int i в long i
- 13: складываем long result и long i
- 14: сохраняем результат обратно в локальную переменную
- Случай 1: в среднем 5100 мс
- Случай 2: в среднем 535 мс
Грабли (Недостатки Автоупаковки)
Любой инструмент при неправильном использовании становится грозным оружием против самого себя. И механизм автоупаковка и распаковка boxing/unboxing в Java не исключение. Первое, очевидное, сравнение через==. Думаю, это понятно, но разберем еще раз:
public static void main(String[] args) {
Integer inCacheValue = 127;
Integer inCacheValue2 = 127;
Integer notInCache = 128; // new Integer(128)
Integer notInCache2 = 128; // new Integer(128)
System.out.println(inCacheValue == inCacheValue2); //true
System.out.println(notInCache == notInCache2); //false
}
В первом случае, значение берется из кэша Integer значений (см. объяснение Boxing выше), а во втором случае будет создаваться каждый раз новый объект. Но тут стоит оговориться. Это поведение зависит от верхней границы кэша (java.lang.Integer.IntegerCache.high). Кроме того, эта граница может измениться и из-за других настроек. У остальных оберток кэш свой: Integer.valueOf() и его аналоги у Short и Long держат значения от -128 до 127, Byte кэширует все свои значения, Character от 0 до 127, Boolean всегда отдает один из двух готовых объектов, а Float и Double не кэшируются вообще. На эту тему можно ознакомиться с обсуждением на stackoverflow: How large is the Integer cache?
Естественно, объекты нужно сравнивать через equals:
System.out.println(notInCache.equals(notInCache2));
Вторая проблема, связанная с этим же механизмом, это производительность. Любая упаковка в Java равносильна созданию нового объекта. Если число не входит в значения из кэша (т.е. в -128 до 127), то будет каждый раз создаваться новый объект. Если вдруг упаковка (т.е. boxing) будет производиться в цикле, это вызовет огромный прирост ненужных объектов и потребление ресурсов на работу сборщика мусора. Поэтому, не стоит слишком безрассудно относиться к этому.
Третьи не менее больные грабли вытекают из того же механизма:
public static void check(Integer value) {
if (value <= 0) {
throw new IllegalStateException("Value is too small");
}
}
В этом коде человек явно пытался не пройти мимо ошибки. Но тут нет проверки на null. Если на вход придет null, то вместо понятной ошибки мы получим невнятный NullPointerException. Потому что для сравнения Java попробует выполнить value.intValue и свалится, т.к. value будет null.Ошибка NoSuchMethodError
В этом блоке я покажу интересный для меня случай с ошибкой NoSuchMethodError, который возникает из-за смешивания классов, скомпилированных до автоупаковки и автораспаковки, с классами, скомпилированными после. Следующий код демонстрирует класс Sum, который мог бы быть написан до J2SE 5. Он перегружает метод add, так чтобы он смог добавлять в итоговую сумму различные типы данных, переданные в него.import java.util.ArrayList;
public class Sum
{
private double sum = 0;
public void add(short newShort)
{
sum += newShort;
}
public void add(int newInteger)
{
sum += newInteger;
}
public void add(long newLong)
{
sum += newLong;
}
public void add(float newFloat)
{
sum += newFloat;
}
public void add(double newDouble)
{
sum += newDouble;
}
public String toString()
{
return String.valueOf(sum);
}
}
До unboxing и autoboxing, любой клиент использующий Sum должен будет обеспечить примитивы для методов add или если они имеют возможность перевести объекты в примитивные типы, они должны будут сделать это, до вызовов методов add. Пример как это можно реализовать:private static String sumReferences(
final Long longValue, final Integer intValue, final Short shortValue)
{
final Sum sum = new Sum();
if (longValue != null)
{
sum.add(longValue.longValue());
}
if (intValue != null)
{
sum.add(intValue.intValue());
}
if (shortValue != null)
{
sum.add(shortValue.shortValue());
}
return sum.toString();
}
J2SE 5 boxing решило эту проблему с большим количеством кода. Unboxing автоматически переводит ссылочный тип в соответствующий ему примитивный, autoboxing наоборот: примитивный тип в соответствующий ему ссылочный. На соответствующем разделе спецификации можно посмотреть какие примитивные типы можно использовать и какие ссылочные типы им соответствуют.private static String sumReferences(
final Long longValue, final Integer intValue, final Short shortValue)
{
final Sum sum = new Sum();
if (longValue != null)
{
sum.add(longValue);
}
if (intValue != null)
{
sum.add(intValue);
}
if (shortValue != null)
{
sum.add(shortValue);
}
return sum.toString();
}
Проверка на null не делает наш код более читаемым, поэтому попробуем изменить исходный класс Sum, то есть, если мы заменим примитивные типы на ссылочные, то проверку будет делать только класс Sum. Тогда им можно легче пользоваться.import java.util.ArrayList;
public class Sum
{
private double sum = 0;
public void add(Short newShort)
{
if (newShort != null)
{
sum += newShort;
}
}
public void add(Integer newInteger)
{
if (newInteger != null)
{
sum += newInteger;
}
}
public void add(Long newLong)
{
if (newLong != null)
{
sum += newLong;
}
}
public void add(Float newFloat)
{
if (newFloat != null)
{
sum += newFloat;
}
}
public void add(Double newDouble)
{
if (newDouble != null)
{
sum += newDouble;
}
}
public String toString()
{
return String.valueOf(sum);
}
}
Изменение API класса Sum (изменение параметров вызова функции) может привести к ошибке NoSuchMethodErrors, если какой-то вовлеченный класс (класс-клиент или одна из версий Sum) скомпилирован на другой версии Java. В частности, если класс-клиент использует примитивы и скомпилирован на Java 4 или предыдущих версиях, а Sum на более поздних версиях (ожидая ссылки, а не примитивного класса), то NoSuchMethodErrors будет возникать (где "S" обозначает, что метод add ожидает примитивного типа short, а "V" что метод возвращает void)
Exception in thread "main" java.lang.NoSuchMethodError: Sum.add(S)V
at Main.main(Main.java:9)
С другой стороны, если клиент скомпилирован на java 5 или позже и обращается к Sum ссылочными переменными, а Sum обрабатывает только примитивные типы данных, то возникнет NoSuchMethodErrors.
Exception in thread "main" java.lang.NoSuchMethodError: Sum.add(Ljava/lang/Short;)V
at Main.main(Main.java:9)
Несколько замечаний и напоминаний для java разработчиков, которые возникают из статьи:
- Classpaths необходим
- .class классы скомпилированные на одинаковых версиях java избежали бы проблемы, возникшей в этом посте
- Classpaths должен быть максимально скудным, чтобы уменьшить/избежать возможность получения случайных "старых" определений класса.
- Операции сборки должны очистить устаревшие классы полностью, и она должна перестраивать нужные классы.
- Автоупаковка и автораспаковка часто очень удобны, но может привести к некоторым проблемам, если не помнить о некоторых правилах. В этом посте, по-прежнему необходимо проверять сравнение объекта с null при распаковывании, так как переводим ссылочный тип в примитивный.
- Один из способов для определения на какой версии java был скомпилирован класс: использовать javap -verbose и взглянуть на соответствующую строку с major version и узнать версию java.
Вывод
Механизм boxing/unboxing позволяет программисту писать меньше кода и даже порой не думать о преобразовании из примитивов в объекты и обратно. Но это не значит, что нужно забывать, как это работает. Иначе можно допустить ошибку, которая может всплыть не сразу. Не стоит полагаться на части системы, которые не полностью под нашим контролем (как например граница integer). Но и не стоит забывать про все преимущества классов-оберток (вродеInteger). Часто эти классы-обертки имеют набор дополнительных статических методов, которые сделают вашу жизнь лучше, а код выразительнее. Вот пример в догонку:
public static void main(String[] args) {
int first = 1;
int second = 5;
System.out.println(Integer.max(first, second));
System.out.println(Character.toLowerCase('S'));
}
Также правильный вывод из всего: нет магии, есть какая-то реализация. И не везде будет всегда то, что мы ожидаем. Например тут нет упаковки:
System.out.println("The number is " + 8);
Пример выше компилятором будет соптимизирован в одну строку. То есть словно вы написали «The number is 8».
И в примере ниже тоже не будет упаковки:
public static void main(String[] args) {
System.out.println("The number is " + Math.abs(-2));
}
Как же так, когда у нас println принимает на вход объект и нужно как-то соединить строки. Строки… да, именно поэтому нет упаковки, как таковой. У Integer есть статические методы, но некоторые из них уровня package. То есть мы их не можем использовать, а вот в самой Java они могут активно использоваться. Вот тут как раз такой случай. Будет вызван метод getChars, который из числа делает массив символов. Опять же, никакой магии, только Java ). Правда, с Java 9 сама сборка строки устроена иначе: компилятор больше не выстраивает цепочку StringBuilder.append(), а ставит одну инструкцию invokedynamic, которая при первом выполнении собирает готовый метод под этот конкретный шаблон строки (JEP 280). Упаковки там по-прежнему нет: число так и остается примитивом.
Так что в любой непонятной ситуации стоит просто посмотреть реализацию и хоть что-то да встанет на свои места.Вопросы и ответы
Что такое автоупаковка и распаковка в Java?
Автоупаковка это автоматическое преобразование примитива в объект класса-обертки: строкуInteger x = 5; компилятор превращает в Integer x = Integer.valueOf(5);. Распаковка обратная операция: int y = x; превращается в int y = x.intValue();. Обе вставки делает компилятор, в исходном коде их не видно, а в байт-коде видно. Механизм появился в Java 5 и описан в спецификации языка.
Чем Integer отличается от int?
int это примитивный тип: он хранит только число, занимает 4 байта и не может быть null. Integer это класс: объект в куче, на который указывает ссылка. Такой объект может быть null, его можно положить в коллекцию или подставить в дженерик, и у него есть набор полезных статических методов (parseInt(), compare(), max()). Плата за удобство: лишняя память, работа сборщика мусора и риск NullPointerException при распаковке.
Почему два Integer со значением 128 не равны при сравнении через ==?
Потому что== для оберток сравнивает ссылки, а не числа. Значения от -128 до 127 метод Integer.valueOf() берет из кэша, обе переменные указывают на один и тот же объект, и == дает true. Для 128 кэша уже нет (если не поднята граница java.lang.Integer.IntegerCache.high), создаются два разных объекта, и == дает false. Обертки сравнивают через equals().
Почему при распаковке возникает NullPointerException?
Потому что распаковка это вызов метода у объекта:int x = value; компилятор превращает в value.intValue(). Если value равен null, обращение к методу заканчивается NullPointerException. Ловушка особенно незаметна в арифметике и сравнениях, вроде строки sum += value или проверки обертки на положительность: вызова метода в коде не видно, а он есть. Поэтому обертку проверяют на null до арифметики.
Насколько автоупаковка замедляет программу?
На арифметике в цикле разница заметна. В примере из этой статьи миллиард сложений с переменной типаLong занял в среднем 5100 мс, а тот же цикл с примитивным long около 535 мс, то есть примерно в 10 раз быстрее. Причина в том, что обертки неизменяемы: каждое сложение создает новый объект, и сборщику мусора приходится их убирать. Там, где упаковка происходит редко, разницы не будет.
Почему появляется NoSuchMethodError при переходе с примитивов на обертки?
В байт-коде примитив и обертка это разные типы, и они входят в сигнатуру метода:add(short) записывается как add(S)V, а add(Short) как add(Ljava/lang/Short;)V. Автоупаковка работает на этапе компиляции, поэтому класс, собранный против примитивной сигнатуры, при запуске не найдет метод с новой сигнатурой и упадет с NoSuchMethodError. Лечится пересборкой всех классов на одной версии Java и коротким classpath.
Читайте также:
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ