Руководство по модификатору static в Java

Введение
В этой статье мы подробно разберем, используемое в языке Java для членов класса ключевое слово static: его применение к переменным, методам, блокам инициализации, вложенным классам (nested classes), а также его влияние на них.
1. Анатомия ключевого слова static
В языке программирования Java ключевым словом static помечают принадлежащие классу, а не его экземпляру элементы (члены) класса.
Это означает, что какое бы количество объектов вы не создали, всегда будет создан только один член, доступный для использования всеми экземплярами класса.
Ключевое слово static применимо к переменным, методам, блокам инициализации, импорту и вложенным классам (nested classes).
2. Статические поля (переменные класса)
В языке Java, если поле объявляется статическим (путем добавления модификатора static), то в независимости от количества созданных объектов класса — всегда будет существовать только один экземпляр статического поля. Значение такого поля будет единым и общим для всех объектов класса, содержащих это поле.
С точки зрения используемой памяти, статические переменные размещаются в специальном пуле в памяти JVM, называемом Metaspace (до Java 8 он назывался Permanent Generation или PermGen, который был полностью удален и заменен на Metaspace).
2.1 Пример статического поля
Предположим, у нас есть класс Car с несколькими атрибутами (полями). Каждый экземпляр данного класса будет иметь свою, отличающуюся от других копию этих переменных экземпляра. Значения таких полей можно менять в объектах независимо друг от друга.
А теперь предположим, что нам требуется переменная-счетчик для хранения количества созданных объектов Car, которая являлась бы общей для всех его экземпляров. При этом необходимо, чтобы каждый из них имел к ней доступ и мог изменять.
Тут-то нам и пригодится статическая переменная (в этом и последующих примерах все поля будут размещаться в порядке их инициализации при создании объекта):
public class Car {

    static int numberOfCars;

    private String name;
    private String engine;  	 
  	 
    public Car(String name, String engine) {
        this.name = name;
        this.engine = engine;
        numberOfCars++;
    }
    
    // getters and setters
}
Теперь для каждого объекта этого класса будет увеличиваться один и тот же экземпляр переменной numberOfCars. Давайте это проверим:
public class CarMain {

    public static void main(String[] args) {
        Car car1 = new Car("Jaguar", "V8");
        Car car2 = new Car("Bugatti", "W16");

        System.out.println("Результат: " + Car.numberOfCars);
    }
}

Результат: 2
2.2 Сериализация статических полей
В Java сериализация позволяет сохранить текущее состояние объекта в поток байтов таким образом, чтобы его можно было передать, например, через сеть. Но, т.к. статические переменные относятся к классу, то они не могут быть сериализованы и просто игнорируются.
Рассмотрим следующий пример (для простоты не используем геттеры и сеттеры) сериализации:
import java.io.Serializable;

public class Student implements Serializable {

    private static final long serialVersionUID = 1234567890l;
    static int age;
    static transient String faculty;

    String firstName;
    transient boolean examPassed;
}
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;

public class SerializationStudent {
    public static void main(String[] args) {
        Student student = new Student();
        student.firstName = "Игорь";
        student.examPassed = true;
        student.age = 22;
        student.faculty = "Факультет авиационных и космических систем";

        try (FileOutputStream fos = new FileOutputStream("./out.txt")) {
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(student);
            System.out.println("Данные сериализованы в out.txt");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
Если запустить файл SerializationStudent.java, то в корне проекта появится файл out.txt, а в консоль будет выведено сообщение:
Теперь запустим файл DeSerializationStudent.java, который достанет из файла out.txt данные и десериализует их обратно в объект Student:
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class DeSerializationStudent {
    public static void main(String[] args) {
        Student student = null;

        try (FileInputStream fis = new FileInputStream("./out.txt")) {
            ObjectInputStream ois = new ObjectInputStream(fis);
            student = (Student) ois.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException c) {
            System.out.println("Класс Student не найден");
            c.printStackTrace();
            return;
        }

        System.out.println("Десериализация объекта Student...");
        System.out.println("Имя студента: " + student.firstName);
        System.out.println("Возраст студента: " + student.age);
        System.out.println("Экзамен сдан? " + student.examPassed);
        System.out.println("Факультет студента: " + student.faculty);
    }
}
В консоль будет выведено следующее:
Как можно увидеть, только переменная firstName была сохранена, а остальные переменные получили значения по умолчанию.
Теперь изменим немного код:
import java.io.Serializable;

public class Student implements Serializable {
    private static final long serialVersionUID = 1234567890l;
    static int age = 17;
    static transient String faculty = "Факультет радиофизики";

    public String firstName = "Катя";
    transient boolean examPassed = true;    
}
Повторно запустим файл DeSerializationStudent.java и получим следующее:
Обратите внимание, и переменная age, и переменная faculty были успешно сериализованы и десериализованы. Для каждой переменной значения были взяты из класса Student, а не из объекта student. Кроме того, переменная faculty является не только static, она еще и transient (ключевым словом transient помечают переменные, состояние которых мы не хотим сериализовать / сохранить. Как раз из-за transient значение examPassed не было сериализовано). В данном случае модификатор static изменяет поведение этой переменной.
2.3 Причины использовать статические поля
  • Когда значение поля должно быть общим для всех объектов класса, в котором оно определено
  • Когда значение поля не зависит от наличия объектов класса, в котором оно определено
  • Когда значение поля может быть изменено из любого объекта класса, в котором оно определено
2.4 Ключевые моменты
  • Статические переменные могут быть созданы только, как переменные класса. Они не могут быть локальными переменными (IDEA выдаст ошибку Modifier 'static' not allowed here или java: illegal start of expression):
public class Main {
    public static void main(String[] args) {
        static String wrongVar = "Modifier 'static' not allowed here";     
        System.out.println(wrongVar);
    }
}
  • К статическим полям класса можно получить доступ без создания объекта, используя имя класса (ссылка на объект не нужна)
В примере с классом Car присвоим 100 статической переменной numberOfCars:
public static int numberOfCars = 100;

А затем обратимся к этому полю через класс Car:
System.out.println("Результат: " + Car.numberOfCars);

Результат: 100
  • Несмотря на то, что получить доступ к статическим полям можно с помощью ссылки на объект (например, car1.numberOfCars), мы должны воздерживаться от её применения, поскольку в этом случае становится не совсем понятно, является ли эта переменная переменной экземпляра или же переменной класса. Вместо этого всегда необходимо ссылаться на статические переменные, используя имя класса (например, Car.numberOfCars)
// Рекомендуется
System.out.println("Результат: " + Car.numberOfCars);

// Не рекомендуется, но работает
System.out.println("Результат: " + car1.numberOfCars);
  • Статические поля не сериализуются (как и transient-переменные). При десериализации такие поля будут иметь значения по умолчанию, заданные JVM при создании объекта
  • Во время десериализации, значение статической переменной может быть выведено, если эта статическая переменная была определена при инициализации базового класса. Это означает, что статическая переменная будет проинициализирована с тем же самым значением, которое было при загрузке класса.
  • Если переменная определена как static и transient, то модификатор static управляет поведением переменной, а не transient. Если мы присвоили такой переменной значение во время загрузки класса, то это значение будет присвоено статической переменной во время десериализации.
3. Статические методы (методы классов)
Подобно статическим полям, статические методы также принадлежат классу, а не объекту, поэтому их можно вызывать без создания экземпляра класса, в котором они находятся. При этом следует помнить, что из статического метода можно получить доступ только к статическим переменным или к другим статическим методам.
public class Test {
    int x;

    public static void main(String[] args) {
        x = 0;
    }
}
В противном случае при компиляции будет выведена ошибка.
3.1 Пример статического метода
Ниже представлен утилитный метод sumOfInts, принимающий на вход последовательность целых чисел и возвращающий сумму этих чисел:
Статические методы обычно используются для выполнения операции, не зависящей от создания экземпляра. При этом, они широко используются для создания служебных (утилитных) или вспомогательных классов, поскольку их можно вызывать без создания нового объекта этих классов.
public class MathUtils {
    public static int sumOfInts(int i, int...nums) {
        int sum = i;

        for(int num : nums) {
     	      sum += num;
        }
        return sum;
    }
}


class Main {
    public static void main(String[] args) {
        System.out.println("Результат: " + MathUtils.sumOfInts(1, 2, 3));
    }
}

Результат: 6
В JDK служебные классы активно используются: Collections, Math, Arrays; StringUtils из Apache или CollectionUtils из Spring framework. Все методы этих классов являются статическими.
3.2 Статические методы в интерфейсах
В версии Java 8 появилась возможность определения статических методов в интерфейсах. Их поведение напоминает поведение методов по умолчанию (default methods), но есть существенные отличия – они не могут быть переопределены методами в реализующих интерфейс классах. Такая особенность позволяет избежать нежелательных результатов, которые возможны в случае некорректной реализации метода в классе.
Пример кода с использованием статического метода в интерфейсе:
public interface Arithmetic {
    public static int multiply(int a, int b) {
        return a * b;
    }
}

class Main {
    public static void main(String[] args) {
        int result = Arithmetic.multiply(2, 3);
        System.out.println(result);
    }
}

Результат: 6
Обратите внимание, что для использования статических методов интерфейса не нужно использовать ключевое слово «implements». Достаточно (и это необходимо) использовать имя интерфейса вместе с именем статического метода. В других случаях, статические методы интерфейса видны только методам этого интерфейса.
3.3 Причины использовать статические методы
  • Для доступа / управления статическими переменными и другими статическими методами, которые не зависят от объектов
  • Для служебных, вспомогательных классов и интерфейсов, поскольку не требуют создания объектов и соответственно, обеспечивают большую производительность
  • Когда методу требуется доступ лишь к статическим полям класса
3.4 Ключевые моменты
  • Статические методы в Java вызываются во время компиляции. Поскольку переопределение метода является частью полиморфизма во время выполнения (Runtime Polymorphism), статические методы не могут быть переопределены. Это справедливо также для статических методов интерфейса
  • Абстрактные методы не могут быть статическими
  • Статические методы не могут использовать ключевые слова this или super
  • Методы экземпляра могут обращаться непосредственно как к методам экземпляра, так и к переменным экземпляра
  • Методы экземпляра также могут непосредственно обращаться к статическим переменным и статическим методам
  • Статические методы могут обращаться ко всем статическим переменным и другим статическим методам
  • Статические методы не могут напрямую обращаться к переменным экземпляра и методам экземпляра. Для этого им нужна ссылка на объект
  • Статические поля и методы не  являются потокобезопасными. Т.к. каждый экземпляр класса имеет одну и ту же копию статической переменной, такая переменная нуждается в защите от одновременного обращения к ней нескольких потоков. Для этого статическая переменная должна быть синхронизирована
  • Статические методы связываются во время компиляции, в отличие от не статических методов, которые связываются во время исполнения. Из-за этого статические методы не могут быть переопределены, т.к. полиморфизм во время выполнения не распространяется на них. Если объявить в классе-наследнике метод с таким же именем и сигнатурой, то он лишь перекроет (hiding methods) метод из суперкласса вместо его переопределения. При обращении к статическому методу, который объявлен как в родительском, так и в дочернем классе, во время компиляции всегда будет вызываться метод исходя из типа переменной. В этом есть смысл, только тогда, когда нет возможности или необходимости переопределения такого метода классами-наследниками.
Рассмотрим небольшой пример:
public class SuperClass {
    public static void staticMethod() {
        System.out.println("SuperClass: внутри статического метода");
    }

    public void notStaticMethod() {
        System.out.println("SuperClass: внутри не статического метода");
    }
}

class SubClass extends SuperClass {
    //Попытка "переопределения" статического метода
    public static void staticMethod(){
        System.out.println("SubClass: внутри статического метода");
    }

    // переопределение не статического метода
    public void notStaticMethod() {
        System.out.println("SubClass: внутри не статического метода");
    }
}

class Main {
    public static void main(String[] args) {
        SuperClass superClassWithSuperCons = new SuperClass();
        SuperClass superClassWithSubCons = new SubClass();
        SubClass subClassWithSubCons = new SubClass();

        superClassWithSuperCons.staticMethod();
        superClassWithSubCons.staticMethod();
        subClassWithSubCons.staticMethod();

        System.out.println();

        superClassWithSuperCons.notStaticMethod();
        superClassWithSubCons.notStaticMethod();
        subClassWithSubCons.notStaticMethod();
    }
}
В итоге получим следующий вывод:
Полученный результат подтверждает написанное выше.
  • Статические методы интерфейса являются частью интерфейса, и мы не можем их использовать для реализации объектов класса
  • Статические методы в интерфейсах могут быть применены для утилитных методов с целью реализации каких-то проверок, например, проверки на null, сортировки и т.д.
4. Статический импорт
В Java 1.5 появилась реализация импорта статических переменных и статических методов класса, что позволяет обращаться к статическим членам класса непосредственно по имени члена, без дополнительного указания имени класса и пакета. Основная цель статического импорта — улучшение читабельности кода программы благодаря устранению постоянного повторения имени класса.
Реализуется статический импорт на основе декларации static import, с последующим указанием импортируемого класса. Например так:
import static java.util.Arrays.asList;
Обратите внимание, что ключевые слова import и static в данном контексте не относятся к модификаторам доступа, поэтому, правило, что «модификаторы доступа могут следовать в любом порядке» здесь не работает.
4.1 Причины использовать статический импорт
Типовыми примерами использования статического импорта могут быть:
1. Тестирование кода (например, на основе библиотеки AssertJ)
// Без статического импорта
Assertions.assertThat(1).isEqualTo(2);

// С использованием статического импорта
import static org.assertj.core.api.Assertions.*;

assertThat(1).isEqualTo(2);
2. Использование методов утилитных классов
List<Integer> numbers = Arrays.asList(1, 2, 3);

// Имя метода "asList" информативно само по себе
List<Integer> numbers = asList(1, 2, 3);
3. Использование перечислений
// получение даты следующей пятницы относительно сегодня
LocalDate.now().with(TemporalAdjusters.next(DayOfWeek.FRIDAY));

// Более элегантное и легкое для чтения выражение
LocalDate.now().with(next(FRIDAY));
Так гладко бывает не всегда, в некоторых случаях статический импорт может ухудшить читабельность кода. Рассмотрим пример без статического импорта:
public class Main {
    public static void main(String[] args) {
        System.out.println(Math.sqrt(Math.abs(Integer.MIN_VALUE) - 
                Math.max(Integer.MAX_VALUE, Integer.MIN_VALUE)));
    }
}
А теперь тот же самый код, но с импортом:
import static java.lang.Integer.*;
import static java.lang.Math.*;
import static java.lang.Math.max;

public class Main {
    public static void main(String[] args) {
        System.out.println(sqrt(abs(MIN_VALUE) - max(MAX_VALUE, MIN_VALUE)));
    }
}
На первый взгляд код стал более читабелен и короче… Но при этом он демонстрирует основные недостатки использования статического импорта:
1. Код стал хуже читаем, поскольку мы «потеряли» информацию о том, в каком классе переменная или метод определены: MAX_VALUE и MIN_VALUE находятся в классе Integer, Long или они определены в написанном самим программистом классе. Таким образом, указание класса (Integer.MAX_VALUE), в данном случае, улучшает читабельность кода
2. Тоже справедливо и для метода max(int a, int b), который определен и в java.lang.Integer и в java.lang.Math. При этом он может быть и самописным методом
Таким образом, каким бы удобным ни был статический импорт, очень важно не злоупотреблять им. Если статический член используется в программе только один или два раза, то его лучше не импортировать. Статический импорт следует оставить на тот случай, если статические члены применяются многократно, как, например, при выполнении целого ряда математических вычислений (но с учетом замечаний, озвученных выше).
4.2 Ключевые моменты
  • Декларация статического импорта пишется в коде как «import static», а не «static import»
  • Если вы импортируете две статических переменных с тем же самым именем, например Integer.MAX_VALUE и Long.MAX_VALUE, то следствием этого вы получите синтаксическую ошибку (их также называют ошибками времени компиляции — Compile-time error)
  • Статический импорт не всегда улучшает читабельность кода как ожидается. Многие программисты предпочитают использовать Integer.MAX_VALUE, что выглядит понятнее, чем MAX_VALUE
  • Вы можете применять статический импорт не только к статическим переменным (или константам), но также к статическим методам
5. Статический блок инициализации
Статический блок используется для инициализации статических переменных. Хотя статические переменные могут быть инициализированы непосредственно во время объявления, бывают ситуации, когда нам требуется выполнить многострочную обработку.
В таких случаях пригодятся статические блоки.
Либо, если статические переменные во время инициализации требуют дополнительной логики, состоящей из нескольких операторов, то также можно использовать статический блок.
5.1 Пример статического блока
Предположим, мы хотим инициализировать объект, представляющий собой список с некоторыми предопределенными значениями.
Это легко сделать с помощью статических блоков:
public class StaticBlockDemo {
    public static List<String> ranks = new LinkedList<>();
    
    static {
        ranks.add("Lieutenant");
    	ranks.add("Captain");
    	ranks.add("Major");
    }
  	 
    static {
        ranks.add("Colonel");
        ranks.add("General");
    }
}
В этом примере невозможно инициализировать объект List всеми начальными значениями вместе с объявлением, поэтому используется статический блок.
Код ниже демонстрирует особенность статических блоков — они выполняются раньше конструкторов и при создании нескольких объектов класса, статический блок выполняется только один раз.
public class Test {
    
    static {
        System.out.println("Вызов статического блока");
    }

    Test() {
        System.out.println("Вызов конструктора");
    }
}

class Main {
    public static void main(String[] args) {
        Test t1 = new Test();
        Test t2 = new Test();
    }
}
На консоль будет выведен следующий результат
5.2 Причины использовать статические блоки
  • Если для инициализации статических переменных требуется дополнительная логика, за исключением операции присваивания
  • Если инициализация статических переменных подвержена ошибкам и требует обработки исключений
5.3 Ключевые моменты
  • У класса может быть несколько статических блоков
  • Статические поля и статические блоки выполняются в том же порядке, в котором они присутствуют в классе
  • Из статического блока нельзя получить доступ к не статическим членам класса
  • Статический блок не может пробросить дальше перехваченные исключения, но может их выбросить. При этом всегда будет выкидываться только java.lang.ExceptionInInitializerError
  • Статические поля или переменные инициализируются после загрузки класса в память в том же порядке, в каком они описаны в классе
6. Статический вложенный класс (nested class)
Язык программирования Java позволяет создавать классы внутри другого класса. Такой класс называется вложенным (nested). Вложенный класс группирует элементы, которые будут использоваться в одном месте, сделав тем сам код более организованным и читабельным.
Вложенные классы бывают двух видов:
  • вложенные классы, объявленные статическими, называются статическими вложенными классами (static nested classes)
  • вложенные классы, объявленные без static, называются внутренними классами (inner classes)
Основное различие между этими понятиями состоит в том, что внутренние классы имеют доступ ко всем членам включающего их класса (включая приватные) верхнего уровня, тогда как статические вложенные классы имеют доступ только к статическим членам внешнего класса.
6.1 Пример статического класса
Наиболее широко используемый подход для создания объектов «одиночка» (singleton) — это статический вложенный класс, поскольку он не требует никакой синхронизации, его легко изучить и реализовать:
public class Singleton  {    
    private Singleton() {}
   
    private static class SingletonHolder {    
        private static final Singleton INSTANCE = new Singleton();
    }    
    
    public static Singleton getInstance() {    
        return SingletonHolder.instance;    
    }    
}
6.2 Причины использовать статический внутренний класс
  • Если какой-то класс используются только в одном другом классе, то их можно сгруппировать, поместив в один общий класс. Это усиливает инкапсуляцию
  • Если вложенный класс не требует какого-либо доступа к членам экземпляра его класса, то лучше объявить его как статический, потому, что таким образом он не будет связан с внешним классом и, следовательно, будет более оптимальным, поскольку ему не потребуется память в куче или в стеке
6.3 Ключевые моменты
  • Статические вложенные классы не имеют доступа к какому-либо члену экземпляра внешнего класса — он может получить к ним доступ только через ссылку на объект
  • Статические вложенные классы могут получить доступ ко всем статическим членам внешнего класса, включая приватные
  • Спецификация Java не позволяет объявлять класс верхнего уровня статическим. Только классы внутри других классов могут быть статическими
  • Опять же, этот класс привязан к внешнему классу и если внешний наследуется другим классом, то этот не будет унаследован. При этом данный класс можно наследовать, как и он может наследоваться от любого другого класса и имплементировать интерфейс
  • По сути статический вложенный класс ничем не отличается от любого другого внутреннего класса за исключением того, что его объект не содержит ссылку на создавший его объект внешнего класса
  • Для использования статических методов/переменных/классов нам не нужно создавать объект данного класса
  • Яркий пример вложенного статического класса — HashMap.Entry, который предоставляет структуру данных внутри HashMap. Стоит заметить, также как и любой другой внутренний класс, вложенные классы находятся в отдельном файле .class. Таким образом, если вы объявили пять вложенных классов в вашем главном классе, у вас будет 6 файлов с расширением .class
7. Константы
Говоря о ключевом слове static, нельзя не упомянуть о его применении в определении констант — переменных, которые никогда не изменяются.
В языке Java существует зарезервированное слово «const», но оно не используется, и Java не поддерживает константы на уровне языка. Выход из ситуации имеется: для определения константы необходимо добавить модификаторы «static final» к полю класса.
Константы — это статические финальные поля, содержимое которых неизменно. Это относится к примитивам, String, неизменяемым типам и неизменяемым коллекциям неизменяемых типов. Если состояние объекта может измениться, он не является константой.
Модификатор static делает переменную доступной без создания экземпляра класса, а final делает ее неизменяемой. При этом нужно помнить, что если мы сделаем переменную только static, то ее легко можно будет изменить, обратившись к ней через имя класса. Если переменная будет иметь только модификатор final, то при создании каждого экземпляра класса она может быть проинициализирована своим значением. Соответственно, используя совместно модификаторы static и final, переменная остается статической и может быть проинициализирована только один раз. В Java константой считается не та переменная, которую нельзя изменить в рамках одного объекта, а та, которую не могут изменить ни один экземпляр класса в котором она находится (такая переменная создается и инициализируется один раз для всех экземпляров, сколько бы их не было).
7.1 Причины использовать константы
Константы улучшают читабельность программного кода, улучшают производительность приложения, поскольку они кешируются как JVM, так и самим приложением. Используя константы вместо чисел, вы практикуете применение паттерна Magic Number.
7.2 Ключевые моменты
  • При использовании констант необходимо соблюдать соглашения об именах. Константы именуются в стиле CONSTANT_CASE: все буквы в верхнем регистре, каждое слово отделено от следующего подчеркиванием.
  • Разместить константы можно в классе, который их будет использовать, в отдельных нескольких классах в соответствии с логикой использования констант, в одном большом отдельном глобальном классе и в интерфейсе. Если константы относятся к перечисляемому типу (например дни недели, месяцы и т.д.), то для таких констант удобнее использовать enum.
Заключение
В этой статье мы рассмотрели ключевое слово static в действии. Мы также прочитали о причинах и преимуществах использования статических полей, статических методов, статических блоков, статический инициализации и статических внутренних классов.
Оцените статью, если она вам понравилась!