final, static и когда их использовать

Три слова, три совершенно разных смысла в зависимости от того, к чему их приставить, — и почти каждое собеседование проверяет, не путаете ли вы их между собой.

Вопрос с собеседования: «Что значит final для переменной, метода и класса — это одно и то же? И почему static-поля называют частой причиной утечек памяти, если Java вроде бы сама убирает мусор?»

Слово одно, а смысл каждый раз новый — это и есть первая ловушка вопроса. Разберём по порядку: сначала final, потом static, а в конце — почему их сочетание требует особой осторожности.

Что выведет этот код?

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

final class Config { // final класс — от него нельзя унаследоваться
    final int maxUsers = 100; // final поле — переприсвоить нельзя
    final List<String> roles = new ArrayList<>(); // final ссылка, но не final содержимое!

    final void printInfo() { // final метод — переопределить нельзя
        System.out.println("Лимит: " + maxUsers + ", ролей: " + roles.size());
    }
}

public class Main {
    public static void main(String[] args) {
        Config config = new Config();
        config.roles.add("admin"); // разрешено — меняем содержимое списка
        config.roles.add("editor");
        config.printInfo();
    }
}

Вывод:

Лимит: 100, ролей: 2

Казалось бы, всё в классе Config помечено final — но список roles прекрасно пополняется. Здесь и кроется главная путаница вокруг final: оно запрещает не «менять данные», а именно переприсваивание.

Как это работает под капотом: final для переменной, метода, класса

final-переменная (поле или локальная переменная). Запрещает переприсвоить саму переменную после инициализации — то есть поменять, на что она указывает (для ссылочных типов) или какое значение хранит (для примитивов). Если переменная ссылочная, как roles в примере выше, final фиксирует именно ссылку на объект в памяти, а не содержимое этого объекта — сам список можно менять сколько угодно, добавлять и удалять элементы. Присвоить roles новый список (roles = new ArrayList<>()) уже не получится — это ошибка компиляции.

final-метод. Запрещает подклассам переопределять этот метод. Используется, когда логика метода критична для корректности класса и переопределение в наследнике может её сломать — например, метод внутри конструктора, вызывающий другие методы объекта.

final-класс. Запрещает наследоваться от этого класса вообще — ни один подкласс создать нельзя. Самый известный пример из стандартной библиотеки — класс String: он final, поэтому вы физически не можете написать class MyString extends String. Это гарантия неизменности и предсказуемости поведения строк во всей Java.

static: одна копия на весь класс, а не на каждый объект

Если обычное (не static) поле принадлежит каждому конкретному объекту — у каждого своя копия, — то static-поле принадлежит самому классу и существует в единственном экземпляре, общем для всех объектов этого класса.

class Counter {
    static int totalCreated = 0; // одно поле на ВСЕ объекты класса
    int id; // а это поле — у каждого объекта своё

    Counter() {
        totalCreated++; // общий счётчик растёт при каждом создании
        id = totalCreated;
    }
}

public class Main {
    public static void main(String[] args) {
        Counter a = new Counter();
        Counter b = new Counter();
        Counter c = new Counter();

        System.out.println("id объекта a: " + a.id);
        System.out.println("id объекта c: " + c.id);
        System.out.println("Всего создано: " + Counter.totalCreated); // обращаемся через имя класса
    }
}

Вывод:

id объекта a: 1
id объекта c: 3
Всего создано: 3

Поле id у каждого объекта своё (1, 2, 3), а totalCreated — общий счётчик, который видят и меняют все три объекта одновременно. Обратите внимание: обращаться к static-полю принято через имя класса (Counter.totalCreated), а не через конкретный объект — хотя технически a.totalCreated тоже сработает, это сбивает с толку и считается плохим стилем.

Почему static — частая причина утечек памяти

Сборщик мусора Java удаляет из памяти только те объекты, на которые больше никто не ссылается. Проблема static-полей в том, что они живут столько же, сколько живёт сам класс, — то есть практически всё время работы программы. Если static-поле — это коллекция, в которую вы добавляете объекты и забываете их удалять, эта коллекция будет держать ссылки на объекты вечно, даже если больше нигде в программе они не используются.

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

class Session {
    // ОПАСНО: static-список растёт всю жизнь программы, если не чистить
    static List<byte[]> cachedData = new ArrayList<>();

    static void addSession(byte[] data) {
        cachedData.add(data); // объекты никогда не удаляются отсюда
    }
}

public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            byte[] data = new byte[1024]; // локальная переменная должна была бы "умереть"
            Session.addSession(data); // но её держит static-список — сборщик мусора не тронет
        }
        System.out.println("Сессий в static-кеше: " + Session.cachedData.size());
    }
}

Вывод:

Сессий в static-кеше: 3

Локальные переменные data внутри цикла обычно живут только до конца итерации, а затем сборщик мусора должен был бы их удалить. Но раз на них ссылается static-поле cachedData, живущее до конца работы программы, — сборщик мусора не может их тронуть. Это классическая утечка памяти: в долго работающем сервере (веб-приложение, которое не перезапускается неделями) такой список будет расти без остановки, пока не закончится память.

Частые ошибки на собеседовании

  • Думать, что final делает объект неизменяемым целиком. Как показал первый пример, final на ссылке не мешает менять содержимое объекта, на который она указывает — фиксируется только сама ссылка.
  • Забывать, что static-методы не видят this и обычные поля напрямую. Раз static-метод принадлежит классу, а не объекту, у него просто нет доступа к нестатическим (принадлежащим конкретному объекту) полям без явного указания объекта.
  • Не приводить пример утечки памяти через static. Мало сказать «static-поля живут долго» — хороший ответ объясняет механизм: static-коллекция держит ссылки, из-за чего сборщик мусора не может освободить память под объекты, которые давно никому не нужны.
  • Путать final-переменную класса с константой в других языках. В Java «настоящая» константа — это обычно комбинация static final (одно значение на класс, и оно неизменяемо), просто final без static — это лишь неизменяемое поле каждого отдельного объекта.

Итоги-шпаргалка

  • final-переменная — нельзя переприсвоить (для ссылки — саму ссылку, а не содержимое объекта).
  • final-метод — нельзя переопределить в подклассе.
  • final-класс — нельзя унаследовать вообще (пример: String).
  • static-поле — одна копия на весь класс, общая для всех объектов; живёт, пока жив класс — то есть почти всю работу программы.
  • Static-коллекции, в которые добавляют объекты и не чистят их, — классический источник утечек памяти: сборщик мусора не может освободить то, на что всё ещё есть ссылка.
Проверьте себя
1. Класс объявил final List<String> items = new ArrayList<>(). Что из этого разрешено?
Aitems = new ArrayList<>() — присвоить новый список
Bitems.add("текст") — добавить элемент в существующий список
CИ то, и другое запрещено
DИ то, и другое разрешено
2. Почему static-коллекция, в которую постоянно добавляют объекты и никогда не удаляют, считается частой причиной утечек памяти?
AПотому что static-поля вообще запрещены в Java с версии 8
BПотому что static-поле живёт, пока жив класс, и держит ссылки на объекты, из-за чего сборщик мусора не может их удалить
CПотому что static-поля хранятся не в куче, а на диске
DПотому что каждый объект дублирует static-поле в своей памяти