Переменные в Java: примитивные и ссылочные типы и их особенности

Переменные в Java: примитивные и ссылочные типы и их особенности Полезное

Тип переменной в Java определяет, что лежит в самой переменной: у примитивного типа — само значение (число, символ, true/false), у ссылочного типа — ссылка на объект в памяти или null. Короткий ответ на частый тестовый вопрос: из вариантов «целочисленные типы, типы с плавающей запятой, массивы, строки» к ссылочным относятся массивы и строки, а целочисленные типы и типы с плавающей запятой — примитивные. Если в вашем варианте теста есть и другие пункты: символьный char и логический boolean — примитивные, классы, интерфейсы и обертки вроде Integer — ссылочные.

Вариант из теста Какие это типы в Java Примитивный или ссылочный
Целочисленные типы byte, short, int, long примитивные
Типы с плавающей запятой float, double примитивные
Массивы int[], String[] и любые другие ссылочные (массив — объект)
Строки String ссылочный (класс из java.lang)
Символьный тип char примитивный
Логический тип boolean примитивный
Классы, интерфейсы, обертки Object, List, Integer ссылочные

Ниже разберем, чем эти две группы отличаются на практике, какие у примитивов диапазоны, зачем нужны обертки вроде Integer и на каких ошибках спотыкаются новички. Примеры проверены на Java 25 (Temurin 25.0.4), запуск одной командой java Имя.java.

Мини-словарь

  • Переменная — именованная ячейка, у которой есть тип и значение.
  • Значение — то, что хранится в переменной: число у примитива или ссылка у ссылочного типа.
  • Объект — данные в куче (heap), созданные через new, литерал строки или литерал массива.
  • Ссылка — значение ссылочной переменной, по которому программа находит объект. Сама ссылка — это не объект.

Упрощение: фраза «примитив хранится в стеке, объект в куче» верна только для локальных переменных. Поле int внутри объекта лежит в куче вместе с объектом, а JIT-компилятор может вообще не создавать объект, если докажет, что он не выходит за пределы метода.

Главное отличие на одном примере

Разница проявляется при присваивании: примитив копируется целиком, а у ссылочного типа копируется только ссылка.

public class CopyDemo {
    public static void main(String[] args) {
        // Примитив: в переменной лежит само значение
        int a = 10;
        int b = a;      // копируется число
        b = 99;
        System.out.println("a = " + a + ", b = " + b);

        // Массив: в переменной лежит ссылка на объект
        int[] x = {1, 2, 3};
        int[] y = x;    // копируется ссылка, объект один
        y[0] = 99;
        System.out.println("x[0] = " + x[0] + ", y[0] = " + y[0]);

        // Строка: тоже ссылка, но объект String неизменяемый
        String s = "Java";
        String t = s;
        t = t + " 25";  // создается новый объект, s не меняется
        System.out.println("s = " + s + ", t = " + t);
    }
}
a = 10, b = 99
x[0] = 99, y[0] = 99
s = Java, t = Java 25

Разбор по строкам. После b = a у переменных два независимых числа, поэтому a осталось 10. После y = x обе переменные указывают на один массив, и изменение через y видно через x. Строка тоже ссылочный тип, но методы и конкатенация String не меняют объект, а возвращают новый — поэтому s не изменилась.

То же правило действует при вызове метода: Java всегда передает аргументы по значению, но для ссылочного типа этим значением является ссылка. Метод может изменить содержимое переданного массива, но не может заставить переменную вызывающего кода указывать на другой массив.

Восемь примитивных типов

Примитивов в Java ровно восемь, их набор задан спецификацией языка (JLS) и не расширяется. Размеры и диапазоны числовых типов одинаковы на любой платформе.

Тип Размер Диапазон Значение поля по умолчанию Литерал
byte 8 бит -128 … 127 0 (byte) 10 или 10 в присваивании
short 16 бит -32 768 … 32 767 0 1000
int 32 бита -2 147 483 648 … 2 147 483 647 0 42, 1_000_000
long 64 бита примерно ±9,22 * 10^18 0L 42L
float 32 бита примерно ±3,4 * 10^38, 6-7 значащих цифр 0.0f 3.14f
double 64 бита примерно ±1,8 * 10^308, 15-17 значащих цифр 0.0d 3.14
char 16 бит '\u0000' … '\uffff' (0 … 65 535) '\u0000' 'A'
boolean не зафиксирован JLS true / false false true

Две оговорки к таблице. Значение по умолчанию получают только поля и элементы массива, а локальную переменную нужно инициализировать явно. char хранит одну кодовую единицу UTF-16, а не «любой символ»: эмодзи и часть редких иероглифов занимают две такие единицы, то есть не помещаются в один char.

Проверка правила про инициализацию:

public class NoInit {
    static int counter;          // поле: по умолчанию 0
    static String name;          // поле-ссылка: по умолчанию null

    public static void main(String[] args) {
        System.out.println(counter + " " + name);
        int local;
        System.out.println(local);
    }
}
NoInit.java:8: error: variable local might not have been initialized
        System.out.println(local);
                           ^
1 error
error: compilation failed

Исправление — присвоить значение до чтения: int local = 0;. После этого программа напечатает 0 null.

Особенности чисел: переполнение, дроби, бесконечность

У числовых примитивов есть поведение, которое не похоже на школьную математику. Все строки ниже взяты из одного прогона.

public class Numbers {
    public static void main(String[] args) {
        int max = Integer.MAX_VALUE;
        System.out.println(max + 1);              // переполнение int
        System.out.println((long) max + 1);       // расширили до long заранее
        System.out.println(0.1 + 0.2);            // двоичная дробь
        System.out.println(1.0 / 0);              // бесконечность
        System.out.println(-1.0 / 0);
        double nan = 0.0 / 0;
        System.out.println(nan + " " + (nan == nan) + " " + Double.isNaN(nan));
        byte small = (byte) 200;                  // явное сужение
        System.out.println(small);
        char c = 'A';
        System.out.println(c + 1);                // char участвует в арифметике как число
        System.out.println((char) (c + 1));
        System.out.println(7 / 2 + " " + 7 / 2.0);
        System.out.println(Math.addExact(max, 1));
    }
}
-2147483648
2147483648
0.30000000000000004
Infinity
-Infinity
NaN false true
-56
66
B
3 3.5
Exception in thread "main" java.lang.ArithmeticException: integer overflow
    at java.base/java.lang.Math.addExact(Math.java:915)
    at Numbers.main(Numbers.java:17)

Что из этого следует:

  • Целые типы при переполнении молча «заворачиваются» в минимальное значение. Если это недопустимо, используйте Math.addExact и аналоги — они бросают ArithmeticException.
  • float и double хранят двоичную дробь, поэтому 0.1 + 0.2 не равно 0.3 точно. Для денег берут BigDecimal или целое число копеек в long.
  • Деление вещественного числа на ноль дает Infinity, а 0.0 / 0 — NaN. NaN не равен даже самому себе, проверять его нужно через Double.isNaN. Целочисленное деление на ноль (1 / 0) бросает ArithmeticException.
  • 7 / 2 при двух int дает 3: дробная часть отбрасывается. Чтобы получить 3.5, хотя бы один операнд должен быть вещественным.

Ссылочные типы: что к ним относится

Ссылочные типы — это все, что не входит в восемь примитивов: классы (включая String и обертки), интерфейсы, массивы, перечисления enum и записи record. У всех ссылочных переменных общие свойства: они могут содержать null, сравнение == проверяет, один ли это объект, а сравнение содержимого делается методом equals.

Типичная ошибка новичка — сравнивать строки через ==:

public class Strings {
    public static void main(String[] args) {
        String a = "otus";
        String b = "otus";
        String c = new String("otus");
        System.out.println(a == b);        // оба литерала - один объект из пула
        System.out.println(a == c);        // разные объекты
        System.out.println(a.equals(c));   // сравнение содержимого
        String input = new java.util.Scanner("otus").next();
        System.out.println(input == a);
        System.out.println(input.equals(a));
    }
}
true
false
true
false
true

Одинаковые строковые литералы спецификация объединяет в один объект, поэтому a == b дает true, но это свойство литералов, а не сравнение текста. Но строка, полученная во время работы программы (из ввода, файла, сети), — отдельный объект, и == возвращает false при одинаковом тексте. Правило простое: строки и любые объекты сравниваем через equals.

Обертки и автоупаковка

Для каждого примитива есть класс-обертка: Byte, Short, Integer, Long, Float, Double, Character, Boolean. Обертка — обычный ссылочный тип, и нужна она там, где язык требует объект. Главный пример — коллекции и дженерики: параметр типа может быть только ссылочным.

import java.util.List;

public class Generic {
    public static void main(String[] args) {
        List<int> numbers = List.of(1, 2, 3);
    }
}
Generic.java:5: error: unexpected type
        List<int> numbers = List.of(1, 2, 3);
             ^
  required: reference
  found:    int
1 error
error: compilation failed

Исправление — List<Integer>. Переводить int в Integer и обратно вручную не нужно: компилятор делает это сам. Это называется автоупаковка (autoboxing, int -> Integer) и автораспаковка (unboxing, Integer -> int). У этого удобства две ловушки, обе видны в примере.

import java.util.ArrayList;
import java.util.List;

public class Boxing {
    public static void main(String[] args) {
        List<Integer> scores = new ArrayList<>();
        scores.add(5);                 // автоупаковка int -> Integer
        int first = scores.get(0);     // автораспаковка Integer -> int
        System.out.println(first * 2);

        Integer x1 = 127, x2 = 127;
        Integer y1 = 128, y2 = 128;
        System.out.println(x1 == x2);       // кэш -128..127
        System.out.println(y1 == y2);       // разные объекты
        System.out.println(y1.equals(y2));  // сравнение значений

        Integer missing = null;
        int value = missing;           // распаковка null
        System.out.println(value);
    }
}
10
true
false
true
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local7>" is null
    at Boxing.main(Boxing.java:18)

Первая ловушка — сравнение оберток через ==. Значения от -128 до 127 при автоупаковке берутся из кэша, поэтому 127 == 127 дает true, а 128 == 128 — false. Верхнюю границу кэша можно поднять настройкой JVM, так что полагаться на == нельзя в любом случае: сравнивайте через equals или распакуйте значения в int.

Вторая ловушка — распаковка null. Обертка может быть null (например, Map.get не нашел ключ), и присваивание в int бросает NullPointerException. Вместо имени переменной в сообщении стоит <local7>, потому что при запуске java Boxing.java класс компилируется без отладочной информации; после javac -g в сообщении будет "missing". Исправление — проверить на null до распаковки или задать значение по умолчанию, например map.getOrDefault(key, 0).

Как выбрать тип переменной

Задача Что взять Когда иначе
Счетчик, индекс, обычное целое int значения выходят за 2,1 млрд — long
Время в миллисекундах, идентификаторы long —
Дробные измерения, физика, графика double огромные массивы при нехватке памяти — float
Деньги BigDecimal или long в копейках —
Флаг да/нет boolean нужно третье состояние «неизвестно» — Boolean с явной обработкой null
Текст String много склеек в цикле — StringBuilder
Элементы коллекции обертки Integer, Long, Double —

Ключевое слово var (с Java 10) не создает особый тип: компилятор выводит обычный статический тип из выражения справа. var n = 5; — это int, var list = new ArrayList<String>(); — это ArrayList<String>. Про символьный тип подробнее — в статье о типе char в Java, а про переменные без привязки к языку — в материале о данных и переменных в программировании.

Выводы

  • В Java восемь примитивных типов: byte, short, int, long, float, double, char, boolean; все остальное, включая массивы и String, — ссылочные типы.
  • Примитив хранит значение и копируется целиком; ссылочная переменная хранит ссылку, и после присваивания две переменные могут указывать на один объект.
  • Целые типы при переполнении заворачиваются без ошибки, а double не подходит для точных денежных расчетов.
  • Строки и обертки сравнивают через equals, а не ==; распаковка null в примитив бросает NullPointerException.
  • Коллекции и дженерики работают только со ссылочными типами, поэтому int в них превращается в Integer через автоупаковку.

Где применяется / связь с практикой

Освойте тему на практике

Выбор между примитивом и ссылочным типом встречается в каждой задаче на Java: от счетчика цикла до модели данных, которую сохраняют в базу через JPA, где Integer вместо int означает «значение может отсутствовать». Систематически освоить типы, коллекции, ООП и работу с памятью с проверкой домашних заданий можно на курсе «Java разработчик. Базовый уровень». Разборы отдельных тем с преподавателями проходят на открытых уроках Otus.

FAQ

Является ли String примитивным типом?
Нет. String — класс, строковая переменная хранит ссылку на объект. Особенность только в том, что у строк есть литералы в двойных кавычках и оператор + для склейки.

Сколько памяти занимает boolean?
Спецификация языка размер не фиксирует. В массиве boolean[] HotSpot обычно тратит 1 байт на элемент, а точный размер поля объекта зависит от JVM и выравнивания.

Можно ли присвоить null переменной типа int?
Нет, это ошибка компиляции: null допустим только для ссылочных типов. Если значение может отсутствовать, используют Integer или OptionalInt.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)