Java Основы Java: типы, ссылки, строки, управление потоком
0%

Основы Java: типы, ссылки, строки, управление потоком

Основы 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

Три наблюдения, которые стоят целой главы учебника:

  1. Все целые типы знаковые, кроме char. Беззнаковой арифметики в языке нет — есть статические методы: Integer.divideUnsigned, Integer.compareUnsigned, Integer.toUnsignedLong, Byte.toUnsignedInt. Байт из сети, который «должен быть 0…255», почти всегда читают как b & 0xFF.
  2. Размер boolean не специфицирован. В массиве JVM обычно отводит байт, в отдельном поле — тоже, но полагаться на это нельзя.
  3. Значения по умолчанию есть только у полей и элементов массива. Локальная переменная без инициализации — ошибка компиляции, а не «мусор в памяти», как в 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.

Приведения типов: где компилятор молчит

Расширяющее преобразование (intlongfloatdouble) происходит автоматически. Сужающее требует явного каста — и молча теряет данные:

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 значащих цифр

Последний пример важен: расширяющее преобразование longfloat компилятор пропускает молча, хотя оно теряет точность. «Автоматически» не значит «безопасно».

Две классические ловушки арифметического продвижения:

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, строка занимает вдвое меньше памяти.

Устройство String: байтовый массив, признак кодировки и суррогатные пары UTF-16

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).

Практический смысл: одиночная конкатенация быстра и читаема, оптимизировать её вручную не нужно. А вот конкатенация в цикле остаётся квадратичной, потому что каждая итерация создаёт новую строку и копирует всё накопленное:

// 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 (слово зарезервировано, но не используется) и нет неявного приведения к booleanif (x) при int x не скомпилируется, что убивает классическую ошибку C if (x = 1).

Как менялись «основы» Java

Вывод из таблицы простой: учебник 2010 года учит другому языку. Код без var, без switch-выражений и с ручным StringBuilder в каждом методе сегодня читается как диалект.

Типичные ошибки: чек-лист

  1. == для строк и обёрток. Только equals. Для примитивов — наоборот, только ==.
  2. int n = map.get(key) при отсутствующем ключе — NPE на распаковке. Используйте getOrDefault.
  3. Тернарный оператор со смешанными int и Integer — NPE, даже если «нулевая» ветка не выбирается визуально.
  4. long ms = 24 * 60 * 60 * 1000 * 365; — переполнение int до присваивания. Добавьте L к первому литералу.
  5. double для денег. Только long в копейках или BigDecimal; сравнение BigDecimal — через compareTo, не equals.
  6. (int) x вместо Math.round(x) — тихая потеря копейки при пересчёте валют.
  7. Конкатенация строк в цикле — квадратичное время. StringBuilder или String.join.
  8. split("."), split("|") — это регулярные выражения, экранируйте.
  9. i % n для индекса при отрицательном i — отрицательный индекс. Берите Math.floorMod.
  10. substring и length() на тексте с эмодзи — резаные суррогатные пары. Работайте через codePoints().
  11. Изменение коллекции внутри for-eachConcurrentModificationException.
  12. var list = new ArrayList<>() — молча получите ArrayList<Object>.
  13. toUpperCase() и String.format без локали — сюрприз на турецкой или русской локали сервера.
  14. Забытый 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 там, где тип очевиден.

Источники

Что дальше

Мы разобрали, что лежит в переменной и как выполняется код. Следующий шаг — как из этих кирпичей собирают типы предметной области: классы и инкапсуляция, интерфейсы и диспетчеризация, а также современный способ моделирования данных через records и sealed-иерархии с pattern matching.

ООП в Java: классы, интерфейсы, наследование, records и sealed-типы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов