Основы Java: типы, ссылки, строки, управление потоком
Синтаксис Java учится за вечер. Проблема в том, что вечером выученный синтаксис не
объясняет, почему 0.1 + 0.2 не равно 0.3, почему две одинаковые строки бывают не равны
через ==, почему list.remove(1) удалил не тот элемент и почему цикл, склеивающий сто
тысяч строк, работает минуту вместо миллисекунд.
Все эти вопросы — про модель значений: что именно лежит в переменной, что копируется при присваивании, где живёт объект и кто отвечает за его время жизни. Java отвечает на них жёстко и однозначно, и если один раз разобрать ответ, девять из десяти «странностей» языка перестают быть странностями.
Эта статья — про фундамент: типы, ссылки, строки и управление потоком. Мы будем постоянно заглядывать на уровень ниже, в JVM, но ровно настолько, насколько нужно для понимания поведения кода; подробный разбор байткода, памяти и сборщиков — в статье JVM изнутри. Предполагается, что JDK установлен и проект собирается — если нет, начните с установки и инструментария.
Развилка, из которой следует всё остальное
В Java ровно два вида типов, и это не стилистическое деление, а разная физика.
Примитив — это фиксированный набор бит, который хранится прямо в слоте переменной: в кадре стека для локальной переменной, внутри объекта для поля, внутри массива для элемента. Копирование примитива — это копирование бит.
Ссылочный тип — это адрес объекта в куче. Копирование ссылки копирует адрес, а не
объект. Отсюда следует всё: и то, что два массива с одинаковым содержимым не равны через
==, и то, что метод может изменить переданный ему объект.
Отдельно стоит тип null — у него нет имени, единственное значение null, и он совместим
с любым ссылочным типом. Примитив null быть не может: у int нет свободной битовой
комбинации под «отсутствие значения». Это фундаментальное различие, из которого позже
вырастут и NullPointerException при распаковке, и Optional
(см. Идиоматика и обработка ошибок).
Восемь примитивов и их точная семантика
В отличие от C, где размер int зависит от платформы, JLS фиксирует всё до бита — это
исходное обещание Java «write once, run anywhere».
| Тип | Бит | Диапазон | По умолчанию | Литерал |
|---|---|---|---|---|
byte |
8 | −128…127 | 0 |
(byte) 42 |
short |
16 | −32 768…32 767 | 0 |
(short) 42 |
int |
32 | ±2,1 млрд | 0 |
42, 0x2A, 0b101010, 1_000_000 |
long |
64 | ±9,2 квинтиллиона | 0L |
42L |
float |
32 | IEEE 754 single | 0.0f |
1.5f |
double |
64 | IEEE 754 double | 0.0d |
1.5, 1e-3 |
char |
16 | 0…65 535 (беззнаковый) | ' ' |
'a', 'к' |
boolean |
не задан | true / false |
false |
true |
Три наблюдения, которые стоят целой главы учебника:
- Все целые типы знаковые, кроме
char. Беззнаковой арифметики в языке нет — есть статические методы:Integer.divideUnsigned,Integer.compareUnsigned,Integer.toUnsignedLong,Byte.toUnsignedInt. Байт из сети, который «должен быть 0…255», почти всегда читают какb & 0xFF. - Размер
booleanне специфицирован. В массиве JVM обычно отводит байт, в отдельном поле — тоже, но полагаться на это нельзя. - Значения по умолчанию есть только у полей и элементов массива. Локальная переменная без инициализации — ошибка компиляции, а не «мусор в памяти», как в C. Компилятор проводит анализ definite assignment (JLS §16) и не пропустит путь, где переменная могла остаться непроинициализированной.
int[] counters = new int[3]; // [0, 0, 0] — поля и массивы обнуляются
int local; // объявили, не присвоили
// System.out.println(local); // ошибка компиляции: variable local might not have been initialized
if (args.length > 0) local = 1;
else local = 2; // теперь обе ветки присваивают — компилятор доволен
System.out.println(local);
Целая арифметика: молчаливое переполнение
Java использует дополнительный код (two’s complement) и переполнение не считает ошибкой — результат просто заворачивается по кругу. Ни исключения, ни предупреждения:
System.out.println(Integer.MAX_VALUE + 1); // -2147483648
System.out.println(Math.abs(Integer.MIN_VALUE)); // -2147483648 (!) модуля нет
System.out.println((byte) 200); // -56
System.out.println(1_000_000 * 1_000_000); // -727379968 — оба операнда int
System.out.println(1_000_000L * 1_000_000); // 1000000000000 — один long делает выражение long
Четвёртая строка — самая опасная: тип выражения определяется типами операндов, а не типом
переменной, в которую вы кладёте результат. long millis = 24 * 60 * 60 * 1000 * 365;
переполнится в int ещё до присваивания.
Когда переполнение недопустимо (деньги, счётчики, идентификаторы), используйте
Math.addExact, multiplyExact, toIntExact — они бросают ArithmeticException:
long id = Math.multiplyExact(userId, 1_000_000L); // лучше упасть, чем тихо испортить данные
int small = Math.toIntExact(someLong); // ArithmeticException: integer overflow
Каноническая иллюстрация того, как переполнение прячется годами, — баг в бинарном поиске из
самой JDK: int mid = (low + high) / 2; переполняется на массивах больше миллиарда
элементов. Правильно — int mid = low + (high - low) / 2; или (low + high) >>> 1. Историю
описал Джошуа Блох в
Nearly All Binary Searches and Mergesorts are Broken.
Деление и остаток тоже полны сюрпризов:
System.out.println(7 / 2); // 3 — целочисленное деление отбрасывает дробь
System.out.println(-7 / 2); // -3 — округление к нулю, а не вниз
System.out.println(-7 % 3); // -1 — знак остатка равен знаку делимого
System.out.println(Math.floorDiv(-7, 2)); // -4 — «математическое» деление вниз
System.out.println(Math.floorMod(-7, 3)); // 2 — то, что обычно нужно для индексов и хэшей
System.out.println(1 / 0); // ArithmeticException: / by zero
System.out.println(1.0 / 0); // Infinity — у дробных деление на ноль легально
Math.floorMod — правильный инструмент для «взять по кругу»: array[floorMod(i, len)]
работает и для отрицательных i, а i % len вернёт отрицательный индекс и уронит программу.
Сдвиги: <<, >> (арифметический, сохраняет знак), >>> (логический, дописывает нули).
Ловушка — счётчик сдвига берётся по модулю:
System.out.println(1 << 32); // 1, а не 0: счётчик маскируется до 5 бит (32 & 31 == 0)
System.out.println(1L << 64); // 1L: для long маска 6 бит
System.out.println(-8 >> 1); // -4
System.out.println(-8 >>> 1); // 2147483644 — знаковый бит уехал вправо как обычный
Дробные числа: IEEE 754 без иллюзий
float и double — двоичные дроби. Числа вроде 0.1 в двоичной системе бесконечны, как 1/3 в
десятичной, поэтому хранится ближайшее представимое значение:
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3); // false
System.out.println(4.35 * 100); // 434.99999999999994
System.out.println((int) (4.35 * 100)); // 434 — «потеряли копейку»
System.out.println(0.1f + 0.2f); // 0.3 — у float меньше цифр, ошибка скрылась
Сравнивать дробные через == можно только в двух случаях: когда вы сравниваете с нулём и
когда точно знаете, что значения получены одинаковой последовательностью операций. В
остальных случаях — сравнение с допуском: Math.abs(a - b) < 1e-9.
Отдельная зона — специальные значения:
double nan = 0.0 / 0.0;
System.out.println(nan == nan); // false — NaN не равен ничему, включая себя
System.out.println(Double.isNaN(nan)); // true — единственный корректный способ
System.out.println(Double.valueOf(nan).equals(nan)); // true (!) equals у обёртки сравнивает биты
System.out.println(0.0 == -0.0); // true
System.out.println(Double.compare(0.0, -0.0)); // 1 (!) а компаратор их различает
Это не педантизм: из-за расхождения == и equals/compare объект с полем double,
попавший ключом в HashMap или TreeMap, может «потеряться». Для сортировок и коллекций
всегда используйте Double.compare.
Начиная с Java 17 (JEP 306) все вычисления с плавающей точкой строго соответствуют IEEE 754
на любой платформе, а ключевое слово strictfp стало избыточным — историческая проблема с
80-битными регистрами x87 закрыта.
Деньги: где double неприемлем
// Плохо: копейки уплывают, отчёты не сходятся
double total = 0.1 + 0.2;
// Хорошо, вариант 1 — целые минимальные единицы (копейки, центы) в long
long cents = 10 + 20; // 30 — точно и быстро
// Хорошо, вариант 2 — BigDecimal, но обязательно из строки или valueOf
BigDecimal a = new BigDecimal("0.1"); // ровно 0.1
BigDecimal b = BigDecimal.valueOf(0.1); // тоже 0.1 — valueOf идёт через Double.toString
BigDecimal wrong = new BigDecimal(0.1); // 0.1000000000000000055511151231257827... — так нельзя
BigDecimal sum = a.add(b); // BigDecimal иммутабелен
BigDecimal price = sum.setScale(2, RoundingMode.HALF_UP); // округление задаём явно
System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1.00"))); // false — scale учитывается
System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1.00"))); // 0 — сравнивайте так
Ловушка BigDecimal.equals регулярно ломает тесты и Set с суммами. Правило: сравнение —
только через compareTo.
десятичная дробь?"} B -->|да| M["long в минимальных единицах
или BigDecimal с явным scale"] B -->|нет| C{"Значение дробное?"} C -->|нет| D{"Помещается
в ±2 млрд?"} D -->|да| INT["int — разумный дефолт"] D -->|нет| E{"Помещается
в ±9 квинтиллионов?"} E -->|да| LONG["long: время, ID, счётчики"] E -->|нет| BIG["BigInteger: криптография,
факториалы"] C -->|да| F{"Критична память
в больших массивах?"} F -->|да| FLT["float — осознанно,
7 значащих цифр"] F -->|нет| DBL["double — дефолт
для физики и статистики"]
Приведения типов: где компилятор молчит
Расширяющее преобразование (int → long → float → double) происходит автоматически.
Сужающее требует явного каста — и молча теряет данные:
long big = 3_000_000_000L;
int truncated = (int) big; // -1294967296 — старшие биты отброшены
double d = 3.99;
System.out.println((int) d); // 3 — каст отбрасывает дробную часть, а не округляет
System.out.println(Math.round(d));// 4 — округление это отдельная операция
System.out.println(Math.round(-2.5)); // -2 (round = floor(x + 0.5), не «от нуля»)
long nanos = 1_234_567_890_123L;
float f = nanos; // расширение без каста... но точность потеряна!
System.out.println((long) f); // 1234567954432 — float хранит ~7 значащих цифр
Последний пример важен: расширяющее преобразование long → float компилятор пропускает
молча, хотя оно теряет точность. «Автоматически» не значит «безопасно».
Две классические ловушки арифметического продвижения:
byte a = 10, b = 20;
// byte c = a + b; // ошибка компиляции: любое арифметическое выражение продвигается до int
byte c = (byte) (a + b);
byte counter = 10;
counter += 300; // компилируется! составное присваивание содержит неявный каст
System.out.println(counter); // 54 — то есть (byte)(10 + 300)
int i = 1;
i += 1.5; // тоже компилируется: i = (int)(i + 1.5)
System.out.println(i); // 2
char ch = 'a';
System.out.println(ch + 1); // 98 — char продвинулся до int
System.out.println((char) (ch + 1)); // b
Правило x += y эквивалентно x = (T)(x + y) — единственное место в языке, где кастинг
происходит без вашего ведома. В C# то же выражение с потерей данных не скомпилируется без
явного каста, и это одно из первых заметных различий двух похожих платформ (подробнее в
Основах C#).
Ссылки, null и вывод типов
Переменная ссылочного типа хранит ссылку — на HotSpot это, как правило, 32-битный сжатый указатель (compressed oop) при куче до ~32 ГБ. Никакого арифметики над указателями в языке нет, никакого «разыменования» вручную — но семантика ровно та же, что у указателя (сравните с указателями в C).
int[] a = {1, 2, 3};
int[] b = a; // копируется ссылка, массив один
b[0] = 99;
System.out.println(a[0]); // 99
System.out.println(a == b); // true — одна и та же ссылка
System.out.println(a == new int[]{99, 2, 3}); // false — разные объекты
System.out.println(java.util.Arrays.equals(a, new int[]{99, 2, 3})); // true — сравнение содержимого
null — это отсутствие ссылки. Любая операция «через точку» на null даёт
NullPointerException. Начиная с Java 14 (JEP 358) сообщение показывает конкретное звено
цепочки — эта мелочь экономит часы отладки:
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "String.trim()" because the return value of
"java.util.Map.get(Object)" is null
Помогающие инструменты стандартной библиотеки:
Objects.requireNonNull(order, "order не должен быть null"); // падаем сразу и с внятным текстом
String name = Objects.requireNonNullElse(user.name(), "аноним");
boolean same = Objects.equals(a, b); // null-безопасное сравнение
int h = Objects.hash(field1, field2); // null-безопасный хэш
В отличие от C# с nullable reference types и Kotlin с String?, система типов Java не
различает «может быть null» и «не может». Это честный проигрыш языка, компенсируемый
дисциплиной: Optional в возвращаемых значениях, аннотации @Nullable/@NonNull из
JSpecify или JetBrains, статический анализ (ErrorProne, NullAway).
var: вывод типа, а не динамическая типизация
С Java 10 (JEP 286) локальные переменные можно объявлять через var. Тип по-прежнему
статический и неизменный — компилятор просто выводит его из инициализатора:
var users = new ArrayList<String>(); // ArrayList<String>
var entry = Map.entry("k", 1); // Map.Entry<String, Integer>
for (var e : userMap.entrySet()) { } // избавляет от Map.Entry<Long, List<Order>>
// var не работает там, где выводить не из чего:
// var x; // нет инициализатора
// var y = null; // нет типа
// var f = () -> 42; // у лямбды нет самостоятельного типа
// поля класса, параметры и возвращаемые типы методов — тоже нельзя
var list = new ArrayList<>(); // ЛОВУШКА: выведется ArrayList<Object>
list.add("строка"); list.add(42); // компилируется, но это уже не типобезопасно
Практическое правило: var уместен, когда тип очевиден из правой части
(var mapper = new ObjectMapper()), и вреден, когда прячет важную информацию
(var result = service.process(x)).
final и «фактически финальные»
final у локальной переменной запрещает переприсваивание, но не запрещает мутацию объекта:
final List<String> log = new ArrayList<>();
log.add("ok"); // можно: меняется объект, а не ссылка
// log = new ArrayList<>(); // нельзя: переприсваивание ссылки
Отдельно важно понятие effectively final: переменная, которую фактически не переприсваивают. Только такие переменные можно захватывать в лямбдах и анонимных классах — компилятор копирует значение в объект замыкания, и изменяемая переменная сделала бы копию рассинхронизированной:
int counter = 0;
List.of("a", "b").forEach(s -> {
// counter++; // ошибка: local variables referenced from a lambda must be final or effectively final
});
// обходной путь — не ломать правило, а взять подходящий тип:
var counter2 = new java.util.concurrent.atomic.AtomicInteger();
List.of("a", "b").forEach(s -> counter2.incrementAndGet());
Автобоксинг: где Java притворяется, что примитивов нет
Дженерики Java работают только с ссылочными типами, поэтому List<int> невозможен, а
List<Integer> — сплошь и рядом. Компилятор автоматически вставляет Integer.valueOf(x) и
intValue(). Удобно — и это источник трёх разных классов багов.
Первый: == сравнивает ссылки, а маленькие значения кэшируются.
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true — оба из кэша Integer для -128..127
System.out.println(c == d); // false — два разных объекта
System.out.println(c.equals(d)); // true — единственно верный способ
Кэш существует, потому что Integer.valueOf обязан возвращать одинаковый объект для
маленьких значений (JLS §5.1.7). Верхнюю границу можно поднять флагом
-XX:AutoBoxCacheMax, но полагаться на это в коде нельзя.
Второй: распаковка null — это NPE в неожиданном месте.
Map<String, Integer> counts = new HashMap<>();
int n = counts.get("нет такого ключа"); // NullPointerException при intValue()
int safe = counts.getOrDefault("ключ", 0); // правильно
Integer maybe = null;
boolean flag = false;
Integer result = flag ? 1 : maybe; // NPE! тернарник продвигает типы до int
Последняя строка — любимая головоломка код-ревью: раз одна ветка имеет тип int, весь
тернарный оператор получает тип int, значит вторая ветка распаковывается, значит null
превращается в NPE. Лечится приведением обеих веток к Integer или явной проверкой.
Третий: производительность. Каждый бокс — потенциальный объект в куче:
long start = System.nanoTime();
Long sum = 0L; // ЛОВУШКА: обёртка вместо примитива
for (int i = 0; i < 10_000_000; i++) sum += i; // распаковка + сложение + упаковка на каждой итерации
// на типичной машине это в 5–20 раз медленнее, чем long sum = 0L;
Плюс память: int — 4 байта, Integer в куче — 16 байт плюс 4 байта на ссылку. Массив
int[1_000_000] занимает ~4 МБ, List<Integer> с теми же данными — около 20 МБ. Именно
поэтому в Stream API есть отдельные IntStream, LongStream, DoubleStream
(см. Stream API), а в проекте Valhalla
годами разрабатывают value-классы, которые должны стереть эту границу.
Массивы: самый низкоуровневый контейнер
Массив в Java — полноценный объект в куче с заголовком и полем длины. Длина фиксируется при
создании и хранится в объекте, поэтому выход за границы — не разрушение памяти, как в C, а
ArrayIndexOutOfBoundsException.
int[] a = new int[3]; // [0, 0, 0]
String[] s = new String[2]; // [null, null]
int[][] grid = new int[3][4]; // массив из трёх ссылок на int[4]
int[][] jagged = new int[3][]; // «рваный»: строки создаём сами
jagged[0] = new int[]{1, 2};
System.out.println(a.length); // 3 — поле, а не метод
System.out.println(Arrays.toString(a)); // [0, 0, 0]
System.out.println(Arrays.deepToString(grid)); // для многомерных — deepToString
int[] copy = Arrays.copyOf(a, 5); // расширяющая копия, хвост занулён
System.arraycopy(a, 0, copy, 0, 3); // быстрое копирование куска
Arrays.fill(a, 7);
Arrays.sort(a);
Главная концептуальная особенность — массивы ковариантны, и это дыра в типобезопасности, оставшаяся с Java 1.0 (тогда не было дженериков, а сортировку надо было писать один раз):
Object[] objects = new String[2]; // компилируется: String[] считается подтипом Object[]
objects[0] = 42; // ArrayStoreException в рантайме
Дженерики так не умеют — List<String> не является List<Object>, ошибка ловится
компилятором. Подробный разбор вариантности — в
Коллекциях и дженериках.
Ещё две ловушки:
int[] x = {1, 2, 3};
System.out.println(x.equals(new int[]{1, 2, 3})); // false — equals у массива это ==
System.out.println(Arrays.equals(x, new int[]{1,2,3})); // true
List<int[]> wrong = Arrays.asList(x); // список из ОДНОГО элемента — самого массива
List<Integer> right = Arrays.stream(x).boxed().toList();
В прикладном коде массивы почти всегда стоит заменить коллекциями: они безопаснее и удобнее.
Массивы остаются там, где важна плотность памяти (byte[] для I/O, int[] для численных
расчётов) и в API стандартной библиотеки.
Строки: самый частый тип и самый частый источник сюрпризов
String иммутабелен. Любой метод, который «меняет» строку, возвращает новую. Это решение
1995 года оказалось одним из самых удачных: иммутабельные строки можно свободно шарить между
потоками, кэшировать хэш-код, класть ключами в HashMap и хранить в пуле литералов.
Внутри с Java 9 (JEP 254, Compact Strings) лежит byte[] плюс однобайтовый признак
кодировки: если все символы помещаются в Latin-1, строка занимает вдвое меньше памяти.
length() считает не символы
Индексация в String — это индексация code unit UTF-16. Для латиницы и кириллицы совпадает с
интуицией, для эмодзи и редких иероглифов — нет:
String s = "a🙂!";
System.out.println(s.length()); // 4 — эмодзи занимает два code unit
System.out.println(s.codePointCount(0, s.length())); // 3 — «символов» три
System.out.println(s.charAt(1)); // половина суррогатной пары, мусор на экране
System.out.println(s.substring(0, 2)); // "a\uD83D" — эмодзи разрезан пополам
s.codePoints() // корректный обход по кодовым точкам
.mapToObj(Character::toString)
.forEach(System.out::println); // a / 🙂 / !
Практический вывод: любые обрезки пользовательского текста (превью комментария, лимит в 200
символов) делайте через codePoints() или библиотеку, понимающую графемные кластеры, иначе
однажды в проде появится «квадратик» вместо эмодзи. То же касается регистра: toUpperCase()
без локали ломает турецкий язык (i → İ) — используйте toUpperCase(Locale.ROOT) для
машинных сравнений.
Пул строк и почему == иногда «работает»
Строковые литералы интернируются: одинаковые литералы в пределах JVM — один и тот же объект.
Из-за этого == иногда даёт true, создавая ложное ощущение правильности:
String s1 = "java";
String s2 = "ja" + "va"; // склеено компилятором в константу
String s3 = new String("java"); // явно новый объект
String part = "ja";
String s4 = part + "va"; // склейка в рантайме — новый объект
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s1 == s4); // false
System.out.println(s1.equals(s4)); // true — так и только так
System.out.println(s1 == s4.intern()); // true — intern кладёт в пул
Правило простое: строки сравнивают только через equals / equalsIgnoreCase, а ==
для строк в код-ревью считают ошибкой по умолчанию.
Конкатенация: что на самом деле делает +
С Java 9 компилятор не строит StringBuilder руками, а вставляет одну инструкцию
invokedynamic, которая при первом исполнении собирает специализированный конкатенатор
(JEP 280).
константы вшиты в него V->>F: bootstrap: makeConcatWithConstants F->>H: собрать конкатенатор под конкретные типы int и boolean H-->>V: CallSite закрепляется навсегда V->>H: все последующие вызовы идут напрямую Note over H: один проход: посчитать длину,
выделить byte[], заполнить
Практический смысл: одиночная конкатенация быстра и читаема, оптимизировать её вручную не нужно. А вот конкатенация в цикле остаётся квадратичной, потому что каждая итерация создаёт новую строку и копирует всё накопленное:
// O(n²) по времени и мусору: на 100 000 итераций — секунды и сотни мегабайт мусора
String bad = "";
for (String part : parts) bad += part;
// O(n): один растущий буфер
StringBuilder sb = new StringBuilder(parts.size() * 16); // подсказка ёмкости экономит копирования
for (String part : parts) sb.append(part);
String good = sb.toString();
// ещё лучше, если задача — просто склеить с разделителем
String joined = String.join(", ", parts);
StringBuffer — синхронизированный предок StringBuilder; в новом коде он не нужен почти
никогда: разделять один буфер между потоками — плохая идея сама по себе.
Современный строковый инструментарий
// Текстовые блоки (Java 15): без экранирования и без склейки
String json = """
{
"id": %d,
"name": "%s"
}""".formatted(42, "Аня"); // formatted — тот же String.format, но в конце цепочки
// Часто нужные методы
" текст ".strip(); // "текст" — Unicode-корректно, в отличие от trim()
"".isBlank(); // true — пустая или только пробелы (Java 11)
"-".repeat(40); // Java 11
"a\nb\nc".lines().toList(); // поток строк без ручного split
String.join("/", "a", "b"); // "a/b"
// split принимает РЕГУЛЯРНОЕ выражение — источник вечных багов
System.out.println(Arrays.toString("1.2.3".split("."))); // [] — точка это «любой символ»
System.out.println(Arrays.toString("1.2.3".split("\\."))); // [1, 2, 3]
System.out.println(Arrays.toString("a,b,,".split(","))); // [a, b] — хвостовые пустые отброшены
System.out.println(Arrays.toString("a,b,,".split(",", -1))); // [a, b, , ] — limit=-1 сохраняет всё
Форматирование: String.format("%.2f", x) зависит от локали (в русской локали разделитель —
запятая!). Для машинных форматов всегда указывайте локаль явно:
String.format(Locale.ROOT, "%.2f", x).
Управление потоком: от if до switch-выражений
Базовые конструкции знакомы всем, кто видел C. Интереснее то, во что они превращаются и где прячутся ошибки.
Условия и короткое замыкание. && и || вычисляют правый операнд только при
необходимости, & и | — всегда. Отсюда идиома if (s != null && !s.isEmpty()): поменяйте
&& на & — и получите NPE.
Цикл for-each — синтаксический сахар с двумя разными раскрытиями. Для массива компилятор
генерирует индексный цикл, для Iterable — работу с Iterator:
for (String s : list) { }
// эквивалентно:
for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
String s = it.next();
}
Отсюда следует главное ограничение: изменять коллекцию внутри for-each нельзя — получите
ConcurrentModificationException. Удалять надо через it.remove() или list.removeIf(...).
Метки позволяют выйти сразу из вложенного цикла — редкая, но легитимная конструкция:
outer:
for (int[] row : matrix) {
for (int v : row) {
if (v < 0) break outer; // выходим из обоих циклов
}
}
switch: старый и новый
Классический switch-оператор работает с byte, short, char, int (и их обёртками),
String и enum. Его главная беда — проваливание (fall-through), когда забыли break:
switch (day) {
case "SAT":
case "SUN":
System.out.println("выходной");
break; // забудете — выполнится и следующая ветка
default:
System.out.println("рабочий");
}
Для строк компилятор генерирует двухступенчатую конструкцию: сначала switch по hashCode,
затем equals — то есть это не линейный перебор, а честный O(1) в среднем. Обратная сторона:
switch по null-строке бросает NullPointerException.
С Java 14 (JEP 361) есть switch-выражение — оно возвращает значение, не проваливается и проверяется на полноту:
int daysInMonth = switch (month) {
case JAN, MAR, MAY, JUL, AUG, OCT, DEC -> 31;
case APR, JUN, SEP, NOV -> 30;
case FEB -> isLeap ? 29 : 28;
// default не нужен: перечислены все константы enum, компилятор это проверил
};
String size = switch (weightKg) {
case 0, 1, 2 -> "малый";
default -> {
var label = compute(weightKg); // блок со сложной логикой
yield label; // yield возвращает значение из блока
}
};
Три причины предпочитать стрелочную форму: нет fall-through, компилятор требует полноты (для
enum и sealed-иерархий), результат можно сразу присвоить final-переменной. Если добавить в
enum новую константу, все исчерпывающие switch-выражения перестанут компилироваться — это
именно то поведение, которое нужно.
С Java 21 switch умеет сопоставление с образцом и явную ветку case null:
String describe(Object o) {
return switch (o) {
case null -> "ничего";
case Integer i when i > 100 -> "большое число " + i;
case Integer i -> "число " + i;
case String s -> "строка длиной " + s.length();
default -> "что-то ещё";
};
}
Полный разбор pattern matching, sealed-иерархий и деконструкции записей — в статье ООП в Java.
Тернарный оператор и прочая мелочь
cond ? a : b — выражение, а не оператор; ловушку с автобоксингом мы разобрали выше. Ещё
две мелочи, о которых спотыкаются: в Java нет goto (слово зарезервировано, но не
используется) и нет неявного приведения к boolean — if (x) при int x не скомпилируется,
что убивает классическую ошибку C if (x = 1).
Как менялись «основы» Java
Вывод из таблицы простой: учебник 2010 года учит другому языку. Код без var, без
switch-выражений и с ручным StringBuilder в каждом методе сегодня читается как диалект.
Типичные ошибки: чек-лист
==для строк и обёрток. Толькоequals. Для примитивов — наоборот, только==.int n = map.get(key)при отсутствующем ключе — NPE на распаковке. ИспользуйтеgetOrDefault.- Тернарный оператор со смешанными
intиInteger— NPE, даже если «нулевая» ветка не выбирается визуально. long ms = 24 * 60 * 60 * 1000 * 365;— переполнениеintдо присваивания. ДобавьтеLк первому литералу.doubleдля денег. Толькоlongв копейках илиBigDecimal; сравнениеBigDecimal— черезcompareTo, неequals.(int) xвместоMath.round(x)— тихая потеря копейки при пересчёте валют.- Конкатенация строк в цикле — квадратичное время.
StringBuilderилиString.join. split("."),split("|")— это регулярные выражения, экранируйте.i % nдля индекса при отрицательномi— отрицательный индекс. БеритеMath.floorMod.substringиlength()на тексте с эмодзи — резаные суррогатные пары. Работайте черезcodePoints().- Изменение коллекции внутри for-each —
ConcurrentModificationException. var list = new ArrayList<>()— молча получитеArrayList<Object>.toUpperCase()иString.formatбез локали — сюрприз на турецкой или русской локали сервера.- Забытый
breakв старомswitch— переходите на стрелочные switch-выражения.
Честно: где эта модель выигрывает и где проигрывает
Выигрывает. Семантика зафиксирована в спецификации до бита: программа считает одинаково
на ARM-ноутбуке и на серверном x86, целочисленное переполнение определено, порядок вычисления
операндов строго слева направо (в отличие от C, где это undefined behavior). Проверка границ
массива и отсутствие арифметики указателей закрывают целый класс уязвимостей — переполнения
буфера в Java-коде просто не бывает. Многословность оборачивается читаемостью чужого кода
через пять лет: явные типы, явные касты, никаких скрытых конверсий, кроме одного
+=-исключения.
Проигрывает. Нет пользовательских типов-значений: любой Point, Money, Complex —
объект в куче с заголовком в 12–16 байт и разыменованием при каждом обращении. Плотные
массивы структур, естественные в C, C# и Rust, в Java эмулируются параллельными массивами
примитивов. Нет беззнаковых типов, нет перегрузки операторов (поэтому BigDecimal считают
через a.add(b).multiply(c)), нет ref/out-параметров, а null не отражён в системе
типов. Отдельная боль — коробочные коллекции: Map<Long, Long> может занимать в разы больше
памяти, чем эквивалентная структура на примитивах, из-за чего в высоконагруженном коде
подключают Eclipse Collections, fastutil или HPPC.
По сравнению с C#/.NET (близкий сосед по нише): там есть struct как настоящий
тип-значение, decimal для денег на уровне языка, беззнаковые типы, checked/unchecked
арифметика, nullable reference types и реифицированные дженерики. Java отвечает на это
предсказуемостью платформы и экосистемой, но по «мелкой моторике» работы с данными C# честно
удобнее. Часть разрыва закроет проект Valhalla с value-классами; пока он в разработке, это
надо просто знать и учитывать при выборе структур данных.
Куда основы Java тащить не стоит. Численный код с плотными матрицами и жёсткими требованиями к раскладке памяти (там C++/Rust/Fortran), скрипты на десять строк (JVM запустится, но экосистема тяжеловата), системы с жёстким реальным временем и микроконтроллеры (пауза GC и объём рантайма). Всё остальное — от бэкенда до потоковой обработки данных — Java делает уверенно.
Мини-итог
- Два вида типов: примитивы хранят значение, ссылки хранят адрес. Всё остальное — следствия.
- Java всегда передаёт аргументы по значению; для ссылочной переменной значением является ссылка, поэтому метод может изменить объект, но не может переприсвоить переменную вызывающего.
- Целая арифметика переполняется молча (
Math.addExact— если это недопустимо), дробная подчиняется IEEE 754 (doubleв деньгах запрещён). - Автобоксинг удобен и опасен:
==на обёртках, NPE на распаковке, лишние объекты в циклах. Stringиммутабелен, внутриbyte[]плюс кодировка, индексация по UTF-16 code unit; конкатенация в цикле квадратична.- Современный поток управления — стрелочные switch-выражения с проверкой полноты вместо
fall-through и
varтам, где тип очевиден.
Источники
- The Java Language Specification, Java SE 21 — первоисточник по типам (глава 4), преобразованиям (глава 5), выражениям (глава 15).
- The Java Tutorials: Language Basics — официальный вводный курс Oracle.
- API-документация java.lang.String — читать целиком хотя бы раз.
- JEP 254: Compact Strings и JEP 280: Indify String Concatenation — как устроены строки внутри.
- JEP 286: Local-Variable Type Inference, JEP 361: Switch Expressions, JEP 378: Text Blocks.
- Joshua Bloch, Effective Java, 3rd ed. — пункты 61 (примитивы вместо обёрток), 63 (конкатенация строк), 6 (лишние объекты): книга на сайте автора.
- What Every Computer Scientist Should Know About Floating-Point Arithmetic — классическая статья Голдберга про IEEE 754.
- The Unicode Standard: Surrogates and Code Points —
почему
length()считает не символы. - Основы двоичного представления и IEEE 754 с нуля — в статьях Двоичная система и биты и Представление данных.
Что дальше
Мы разобрали, что лежит в переменной и как выполняется код. Следующий шаг — как из этих кирпичей собирают типы предметной области: классы и инкапсуляция, интерфейсы и диспетчеризация, а также современный способ моделирования данных через records и sealed-иерархии с pattern matching.
ООП в Java: классы, интерфейсы, наследование, records и sealed-типы