В этой статье мы рассмотрим такую функцию в Java как автоупаковка/распаковка. Автоупаковка и распаковка это функция преобразования примитивных типов в объектные и наоборот.

Вступление

Язык программирования, как и язык на котором говорят люди, живёт и меняется, в нём появляются новые явления, чтобы языком было пользоваться удобнее. А как мы знаем язык должен удобно выражать наши мысли. В версиях ниже 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 файл для вашего класса. Мы увидим содержимое вроде такого:
Автоупаковка и распаковка в Java - 2
Этот тот самый пресловутый «байткод». Но важно нам сейчас то, что мы видим. Сначала в стэк выполнения метода помещается примитив 8080, а дальше выполняется Integer.valueOf. Это и есть та «магия» boxing. А внутри магия выглядит вот так:
Автоупаковка и распаковка в Java - 3
То есть по сути будет взят новый Integer или будет получен Integer из кэша (кэш ничто иное как просто массив из Integer) в зависимости от величины значения числа. Естественно, Integer не одному так повезло. Есть целый список соотносящихся примитивных типов и их обёрток (классов, представляющие примитивы в мире ООП). Данный список приведён в самом низу Tutorial от Oracle: «Autoboxing and Unboxing». Стоит сразу отметить, что массивы из примитивов не имеют «обёртки» без подключения каких-либо сторонних библиотек. Т.е. Arrays.asList не сделает из int[] для нас List из Integer’ов.

Распаковка 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. Если мы посмотрим в байткод, то так и есть:
Автоупаковка и распаковка в Java - 4
Как видно, никакой магии. Всё в рамках Java. Просто оно работает «само». Для нашего удобства. Когда используется автоупаковка и распаковка? Автоупаковка применяется компилятором Java в следующих условиях:
  • Когда значение примитивного типа передается в метод в качестве параметра метода, который ожидает объект соответствующего класса-оболочки.
  • Когда значение примитивного типа присваивается переменной, соответствующего класса оболочки.
  • Когда объект передается в качестве параметра методу, который ожидает соответствующий примитивный тип.
  • Когда объект присваивается переменной соответствующего примитивного типа.

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 – и сохраняем обратно в переменную.
Таким образом, в нашем цикле упаковка и распаковка выполнятся, всего-то, по миллиарду раз. Помимо этого, тип Long является immutable, а это значит, что после каждого вызова метода упаковки Long.valueOf() (строка №20 байт-кода) мы получаем новый объект, при этом старый объект остаётся в памяти и ждёт, когда за ним придёт GC. В итоге, к концу работы программы, сборщик мусора на моей машине срабатывал в среднем 46 раз. Случай 2 Попробуем заменить тип переменной result на примитивный long:
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 мс
Почти 10-кратный прирост по скорости. Таким образом, если в коде используются классы-обёртки и с ними иногда необходимо проводить некоторые арифметические операции, то лучше производить распаковку заранее, не полагаясь на то, что JVM всё за вас оптимизирует, как я раньше и считал.

Грабли (Недостатки Автоупаковки)

Любой инструмент при неправильном использовании становится грозным оружием против самого себя. И механизм автоупаковка и распаковка boxing/unboxing в Java не исключение. Первое, очевидное, сравнение через ==. Думаю, это понятно, но разберём ещё раз:

public static void main(String[] args) {
    Integer inCacheValue = 127;
    Integer inCacheValue2 = 127;
    Integer notInCache = 128; // new Integer(129)
    Integer notInCache2 = 128; // new Integer(129)
    System.out.println(inCacheValue == inCacheValue2); //true
    System.out.println(notInCache == notInCache2); //false
}

В первом случае, значение берётся из кэша Integer значений (см. объяснение Boxing выше), а во втором случае будет создаваться каждый раз новый объект. Но тут стоит оговориться. Это поведение зависит от верхней границы кэша (java.lang.Integer.IntegerCache.high). Кроме того, эта граница может измениться и из-за других настроек. На эту тему можно ознакомиться с обсуждением на 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 наоборот тип данных передает в соответствующий ему тип данных. На section можно посмотреть какие примитивные типы можно использовать и какие ссылочные типы им соответствуют. Автоупаковка, автораспаковка - 1Изменена предыдущая программа с функцией "автоматическая распаковка", но по-прежнему необходимо избегать появления NullPointerException.
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 ). Так что в любой непонятной ситуации стоит просто посмотреть реализацию и хоть что-то да встанет на свои места.