← назад к разделу

Если вы уже писали код на другом языке, Java покажется одновременно знакомой и непривычно строгой. Здесь у каждой переменной заранее объявлен тип, программа не запустится, пока не скомпилируется, а часть привычных вольностей просто запрещена. Разберём базу: как объявить переменную, чем int отличается от Integer, почему строки нельзя изменить и из чего состоит управляющая логика.

переменная объект в куче int count = 100; 100 значение лежит прямо в переменной Integer boxed = 100; ссылка Integer 100 100 Integer none = null; null int x = none; → ссылки нет, программа падает с NullPointerException

Примитив хранит значение прямо в переменной. Обёртка хранит только ссылку: автобоксинг создаёт объект в куче, анбоксинг достаёт значение обратно — а если ссылки нет, распаковка `null` роняет программу.

Обязательно

Зачем такая строгость

Java — язык со статической типизацией. Это значит, что тип каждой переменной известен ещё до запуска программы, на этапе компиляции. Компилятор проверяет, что вы не складываете число со списком и не вызываете у строки несуществующий метод. Ошибки такого рода ловятся сразу, а не во время работы у пользователя.

Короткая формула: в Java сначала компиляция, потом запуск. Исходный код (.java) превращается в байт-код (.class), который исполняет виртуальная машина JVM. Поэтому опечатка в имени метода — это не падение у пользователя, а ошибка сборки прямо в редакторе.

App.java исходный код javac проверка типов App.class байт-код JVM исполняет байт-код

Путь от файла до запуска: выделен javac, потому что именно он проверяет типы и имена, и до JVM доходит только то, что проверку прошло.

Точка входа: метод main

Любая Java-программа начинается с метода main. JVM ищет именно его и запускает первым:

живой пример

public class App {
    public static void main(String[] args) {
        System.out.println("Привет, Java"); // вывод строки в консоль
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Сигнатура по словам:

  • public — метод доступен извне, JVM должна его увидеть;
  • static — метод принадлежит классу, а не объекту, его можно вызвать без создания экземпляра;
  • void — метод ничего не возвращает;
  • String[] args — массив аргументов командной строки.

Что стоит за class, public и static, разобрано в статье про ООП.

Переменные и var

Переменная объявляется по схеме «тип имя = значение»:

int age = 30;
String name = "Анна";
boolean active = true;

Тип слева обязателен — компилятор должен знать, что вы собираетесь хранить. Но с Java 10 появилось ключевое слово var: оно просит компилятор вывести тип автоматически из правой части.

var age = 30;          // компилятор видит число → int
var name = "Анна";     // видит строку → String
var items = new ArrayList<String>(); // тип ясен из конструктора

Важно: var — это не «нетипизированная» переменная, как в динамических языках. Тип всё равно фиксируется намертво, просто его не пишут руками. Записать потом в age строку нельзя. И var работает только там, где тип очевиден из значения: для локальных переменных внутри методов, для переменной цикла for, для ресурса в try (var conn = ...) и для параметров лямбды. В полях класса, параметрах и типе результата метода var писать нельзя — там тип нужен тем, кто читает сигнатуру.

Примитивы против объектов

В Java есть два мира значений.

Примитивные типы — это «голые» значения, лежащие в памяти напрямую. Их восемь, но на старте важны эти:

int count = 100;        // целое, 32 бита
long big = 9_000_000_000L;  // 64 бита: в int такое уже не влезает
double distance = 19.99; // дробное
boolean ok = false;     // true / false
char letter = 'A';      // один символ в одинарных кавычках

Остальные три — byte и short (те же целые числа, только уже: 8 и 16 бит) и float (дробное, вдвое короче double и вдвое грубее). В обычном коде они попадаются редко, но в схемах базы данных и в разговоре про точность всплывут.

Про double сразу одна оговорка. Он хранит дробь приближённо, поэтому 0.1 + 0.2 в нём не равно 0.3, а копейки после десятка арифметических действий разъезжаются. Цену в примере так писать можно, деньги в настоящем коде — нет: для них берут BigDecimal или считают в копейках обычным long.

Объектные (ссылочные) типы — это всё остальное: строки, списки, ваши собственные классы. Переменная хранит не само значение, а ссылку на объект в памяти. У каждого примитива есть объектный «двойник»-обёртка: int → Integer, long → Long, boolean → Boolean, double → Double.

Зачем нужны обёртки? Многие части стандартной библиотеки (например, коллекции) умеют работать только с объектами, а не с примитивами. В список нельзя положить int, но можно Integer.

Автобоксинг

Чтобы не переключаться между мирами вручную, Java делает преобразование сама — это автобоксинг (примитив → обёртка) и анбоксинг (обёртка → примитив). Запустите и посмотрите на вывод:

живой пример

public class Boxing {
    public static void main(String[] args) {
        Integer boxed = 5;    // автобоксинг: int 5 → Integer
        int back = boxed;     // анбоксинг: Integer → int
        System.out.println("boxed=" + boxed + ", back=" + back);

        Integer a = 100, b = 100;
        Integer x = 1000, y = 1000;
        System.out.println("100 == 100   → " + (a == b));
        System.out.println("1000 == 1000 → " + (x == y));
        System.out.println("equals       → " + x.equals(y));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Две последние строки и есть главная ловушка. == для объектов сравнивает ссылки, а не значения, и с маленькими числами это особенно коварно: значения от −128 до 127 Java кэширует и переиспользует, поэтому две сотни — это один и тот же объект и == даёт true, а две тысячи — уже разные объекты и false. Причём верхнюю границу кэша можно сдвинуть ключом запуска -XX:AutoBoxCacheMax=2000 — и тогда тот же самый код напечатает true там, где печатал false. Поэтому «до 127 работает» — не правило, на которое можно опереться, а объяснение, почему нельзя опираться на ==. Сравнивать обёртки нужно через .equals(); подробнее — в материале про коллекции.

Вторая ловушка: обёртка может быть null, и попытка распаковать null в примитив уронит программу с NullPointerException. Правило простое: если значение точно есть и оно одно — берите примитив int; если значение может отсутствовать или его надо положить в коллекцию — берите Integer.

Строки и их неизменяемость

String — это объект, и в Java строки неизменяемы. Любая операция, которая «меняет» строку, на самом деле создаёт новую, а старая остаётся прежней. Заодно видно, чем .equals() отличается от ==:

живой пример

public class Strings {
    public static void main(String[] args) {
        String greeting = "Привет";
        String full = greeting + ", мир";   // создалась НОВАЯ строка
        System.out.println(greeting);       // «Привет» — не изменилась
        System.out.println(full);

        var sb = new StringBuilder();
        for (int i = 0; i < 5; i++) {
            sb.append(i).append(' ');       // меняем один и тот же буфер
        }
        System.out.println(sb.toString());

        String copy = new String("Привет");
        System.out.println(copy.equals(greeting) + " — содержимое совпало");
        System.out.println((copy == greeting) + " — а объекты разные");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Неизменяемость сделана ради безопасности и предсказуемости: раз строку нельзя испортить, её можно спокойно передавать куда угодно и использовать как ключ. Минус — если в цикле склеивать тысячи строк через +, каждый раз создаётся новый объект; для такого случая и нужен StringBuilder, который меняет один буфер.

Неизменяемость даёт и второй эффект, ради которого в примере выше две строки с одинаковым текстом оказались разными объектами. Строковые литералы в коде Java держит в общем хранилище, пуле строк: когда в двух местах программы написано "Привет", это один объект, и == для них даст true. Так можно, потому что строку никто не испортит. А new String("Привет") создаёт отдельный объект в обход пула, и == с литералом уже false, хотя содержимое то же. Склейка констант вроде "при" + "вет" тоже сворачивается компилятором в литерал и попадает в пул; склейка с переменной создаёт новый объект. Метод intern() возвращает экземпляр из пула для любой строки, но в прикладном коде он почти не нужен. Вывод для вас один: == на строках иногда работает случайно, и полагаться на это нельзя; сравнивайте через equals.

А с Java 15 для многострочного текста есть «текстовые блоки» в тройных кавычках:

String json = """
        {
          "name": "Анна"
        }
        """;

Операторы

Арифметика, сравнение и логика записываются как почти в любом языке, и одна деталь в них важнее всего остального: логические && и || ленивы. Если левая часть && уже false, правую Java не вычисляет, поэтому if (user != null && user.isActive()) не падает на null: до второго условия дело не доходит. Поменяйте части местами, и проверка на null перестанет защищать.

int sum = 2 + 3;        // арифметика: + - * / %
boolean adult = age >= 18;     // сравнение: == != < > <= >=
boolean both = active && adult; // логика: && (и), || (или), ! (не)

Есть и тернарный оператор — короткая форма выбора одного из двух значений:

String label = adult ? "взрослый" : "несовершеннолетний";

Управляющие конструкции

Ветвление — if / else if / else:

if (price > 100) {
    System.out.println("дорого");
} else if (price > 50) {
    System.out.println("средне");
} else {
    System.out.println("дёшево");
}

Циклы — for, while и for-each для перебора коллекций:

for (int i = 0; i < 3; i++) {       // классический счётчик
    System.out.println(i);
}

int n = 3;
while (n > 0) {                      // пока условие истинно
    n--;
}

var names = List.of("Анна", "Борис");
for (String person : names) {        // for-each: по каждому элементу
    System.out.println(person);
}

Выбор по значению — switch. В современной Java есть короткая стрелочная форма, которая сразу возвращает результат и не требует break:

живой пример

public class Roles {
    public static void main(String[] args) {
        for (String role : new String[]{"admin", "editor", "guest"}) {
            int level = switch (role) {
                case "admin" -> 3;
                case "editor" -> 2;
                default -> 1;               // обязательная ветка «всё остальное»
            };
            System.out.println(role + " → " + level);
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Старая форма с case ... : и break тоже работает, но стрелочный switch короче и не даёт случайно «провалиться» в следующую ветку.

Дополнительно: при первом чтении можно пропустить

Глубже: массивы: объявление, length, многомерные и Arraysрасширенное

Статья про коллекции начинается словами «массив в Java фиксированного размера», как о чём-то известном, а в этой фазе массив ещё не встречался. Вот он целиком, потому что под каждым ArrayList лежит именно он.

Массив это блок из фиксированного числа ячеек одного типа. Размер задаётся при создании и не меняется; индексы от нуля до length - 1, и выход за них роняет программу с ArrayIndexOutOfBoundsException. Ячейки заполняются значением по умолчанию: ноль для чисел, false для boolean, null для объектов.

живой пример

import java.util.Arrays;

public class ArraysDemo {
    public static void main(String[] args) {
        int[] scores = new int[3];
        scores[0] = 7;
        String[] names = {"Анна", "Борис", "Вера"};
        System.out.println(scores.length + " " + scores[1] + " " + names[2]);

        int[] sorted = {5, 1, 4};
        Arrays.sort(sorted);
        System.out.println(Arrays.toString(sorted));
        System.out.println(Arrays.toString(Arrays.copyOf(sorted, 5)));

        int[][] grid = new int[2][3];
        grid[1][2] = 9;
        System.out.println(Arrays.deepToString(grid));
        System.out.println(sorted.equals(new int[]{1, 4, 5}) + " " + Arrays.equals(sorted, new int[]{1, 4, 5}));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Пять вещей из вывода. length это поле, а не метод, в отличие от size() у коллекций и length() у строки. Arrays.toString нужен, потому что сам массив печатается как [I@1b6d3586, адрес, а не содержимое; для вложенных массивов deepToString. Arrays.copyOf это единственный способ «увеличить» массив: создать новый и скопировать. Многомерный массив это массив массивов, и строки в нём могут быть разной длины. И equals у массива сравнивает ссылки, а не содержимое, для содержимого есть Arrays.equals; та же история с hashCode, и поэтому массив плохой ключ для HashMap.

Что ещё есть в Arrays: asList делает список фиксированного размера поверх массива (добавить нельзя, изменить элемент можно), fill заполняет, binarySearch ищет в отсортированном, stream превращает в поток. В прикладном коде массив встречается реже коллекций: String[] args, буферы байтов, результаты из библиотек; но ArrayList внутри это массив с копированием при росте, и его поведение из раздела про коллекции объясняется именно этим.

Глубже: стек и куча: в Java всё передаётся по значениюрасширенное

Три темы фазы объясняют один и тот же механизм каждая заново: изменяемый ключ в HashMap, защитные копии в record, корни для сборщика мусора. Механизм один, и его стоит знать отдельно.

У работающей программы две области памяти. Стек у каждого потока свой: при вызове метода на нём появляется кадр с локальными переменными и параметрами, при возврате кадр исчезает. Куча общая: там живут все объекты, созданные через new, и туда же попадают строки и массивы. Локальная переменная примитивного типа хранит само значение прямо в кадре; локальная переменная объектного типа хранит ссылку, адрес объекта в куче.

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

живой пример

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

public class ByValue {
    static void change(int n, List<String> list, StringBuilder sb) {
        n = 42;
        list.add("добавлено внутри");
        sb = new StringBuilder("новый объект");
    }

    public static void main(String[] args) {
        int n = 1;
        List<String> list = new ArrayList<>();
        StringBuilder sb = new StringBuilder("исходный");
        change(n, list, sb);
        System.out.println(n + " | " + list + " | " + sb);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

Что из этого следует для остальной фазы. Ключ HashMap это ссылка на объект, и если объект изменить после put, ключ в таблице «переедет» вместе с ним, а его хеш останется старым. record с полем-списком хранит ссылку на список, и тот, кто передал список, может менять его дальше, отсюда защитная копия. Сборщик мусора начинает от корней, и локальные переменные в кадрах стека это главные корни: объект жив, пока на него есть ссылка с какого-нибудь стека. И String неизменяем именно затем, чтобы передавать ссылку на него было безопасно.

Глубже: числа и точность: деление, переполнение, double и BigDecimalрасширенное

Оговорка про double выше требует продолжения, потому что ошибок с числами четыре, и все четыре молчаливые: программа не падает, а считает неверно.

живой пример

import java.math.BigDecimal;
import java.math.RoundingMode;

public class Numbers {
    public static void main(String[] args) {
        System.out.println(7 / 2 + " " + 7 / 2.0 + " " + 7 % 2);
        System.out.println(Integer.MAX_VALUE + 1);
        System.out.println(0.1 + 0.2);
        System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
        BigDecimal price = new BigDecimal("19.99");
        System.out.println(price.multiply(new BigDecimal("3")).setScale(2, RoundingMode.HALF_UP));
        System.out.println(new BigDecimal("1.10").equals(new BigDecimal("1.1")) + " " + (new BigDecimal("1.10").compareTo(new BigDecimal("1.1")) == 0));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Целочисленное деление. 7 / 2 даёт 3: оба операнда целые, дробная часть отбрасывается без предупреждения; 7 / 2.0 даёт 3.5, потому что один операнд дробный. Средний балл, посчитанный как sum / count при целых sum и count, всегда будет округлён вниз, и это самая частая ошибка первого месяца.

Переполнение. int вмещает числа до двух с небольшим миллиардов, и Integer.MAX_VALUE + 1 даёт отрицательное число, а не ошибку: биты переворачиваются по кругу. Счётчик просмотров, сумма в копейках за год, идентификатор из внешней системы упираются в это; для них long, а для проверки на переполнение Math.addExact, который бросает исключение вместо тихого мусора.

Погрешность double. 0.1 + 0.2 даёт 0.30000000000000004: двоичная дробь не может точно представить десятичную, как десятичная не может точно представить треть. Для расстояний, физики и графики это несущественно; для денег недопустимо, потому что копейки разъезжаются после нескольких операций и не сходятся с бухгалтерией.

BigDecimal для денег. Хранит десятичное число точно, считает без потерь, округляет только там, где вы явно попросили (setScale с режимом). Создают его из строки, а не из double (new BigDecimal(0.1) уже содержит погрешность), сравнивают через compareTo, потому что equals учитывает масштаб и считает 1.10 и 1.1 разными. В базе денежная колонка это numeric, а не float, о чём статья про типы чисел в PostgreSQL; в JSON сумму отдают строкой или целым в копейках, о чём статья про формат ответа в разделе REST.

Коротко

  • Java — статически типизированный язык: тип известен на этапе компиляции, ошибки ловятся до запуска.
  • Любая программа стартует с метода public static void main(String[] args).
  • var не отменяет типизацию — он лишь выводит тип автоматически для локальных переменных.
  • Примитивы (int, double, boolean) хранят значение напрямую; объектные обёртки (Integer, Double) хранят ссылку, могут быть null и нужны для коллекций. Перевод между ними — автобоксинг.
  • Обёртки и строки сравнивают через .equals(): == сравнивает ссылки, на числах больше 127 это видно сразу, а на строках иногда работает случайно из-за пула литералов.
  • Строки неизменяемы: «изменение» создаёт новый объект, для активной склейки есть StringBuilder.
  • Управляющие конструкции стандартны: if/else, for, while, for-each и современный стрелочный switch.
  • Массив это блок фиксированного размера с length, индексами от нуля и значениями по умолчанию; печать, сравнение и копирование через Arrays, «увеличить» значит скопировать.
  • Примитивы лежат в кадре стека, объекты в куче, переменная хранит ссылку; всё передаётся по значению, включая ссылки: метод меняет объект, но не подменяет его у вызывающего.
  • Целое на целое даёт целое, int переполняется молча, double хранит дробь приближённо; деньги это BigDecimal из строки со сравнением через compareTo.

Что почитать дальше