Являются ли инициализация объекта в Java "Foo f = new Foo ()" по существу такой же, как использование malloc для указателя в C? - PullRequest
8 голосов
/ 23 октября 2019

Я пытаюсь понять реальный процесс создания объектов в Java - и я предполагаю, что другие языки программирования.

Было бы неправильно предполагать, что инициализация объекта в Java такая же, как при использовании malloc дляструктура в C?

Пример:

Foo f = new Foo(10);
typedef struct foo Foo;
Foo *f = malloc(sizeof(Foo));

По этой причине говорят, что объекты находятся в куче, а не в стеке? Потому что они по сути всего лишь указатели на данные?

1 Ответ

5 голосов
/ 23 октября 2019

В C malloc() выделяет область памяти в куче и возвращает указатель на нее. Это все, что вы получаете. Память неинициализирована, и вы не можете гарантировать, что это все нули или что-то еще.

В Java вызов new выполняет распределение на основе кучи, как и malloc(), но вы также получаете массу дополнительных удобств (или накладные расходы, если вы предпочитаете). Например, вам не нужно явно указывать количество байтов для выделения. Компилятор выяснит это для вас в зависимости от типа объекта, который вы пытаетесь выделить. Кроме того, вызываются конструкторы объектов (которым вы можете передавать аргументы, если хотите контролировать процесс инициализации). Когда возвращается new, вы гарантированно получите инициализированный объект.

Но да, в конце вызова оба результата malloc() и new просто являются указателями на некоторый фрагментДанные на основе кучи.

Вторая часть вашего вопроса касается различий между стеком и кучей. Гораздо более полные ответы можно найти, пройдя курс по (или читая книгу) о дизайне компилятора. Курс по операционным системам также будет полезен. Есть также многочисленные вопросы и ответы на SO о стеках и кучах.

Сказав это, я дам общий обзор, который, я надеюсь, не слишком многословен и имеет целью объяснить различия на довольно высоком уровне. .

По сути, основная причина наличия двух систем управления памятью, то есть кучи и стека, заключается в эффективности . Вторая причина заключается в том, что каждый из них лучше справляется с определенными типами задач, чем другой.

Стеки мне легче понять как концепцию, поэтому я начну со стеков. Давайте рассмотрим эту функцию в C ...

int add(int lhs, int rhs) {
    int result = lhs + rhs;
    return result;
}

Вышеприведенное выглядит довольно просто. Мы определяем функцию с именем add() и передаем левые и правые дополнения. Функция добавляет их и возвращает результат. Пожалуйста, игнорируйте все мелочи, такие как переполнения, которые могут произойти, на данный момент это не уместно для обсуждения.

Цель функции add() кажется довольно простой, но что мы можем сказать о ее жизненном цикле? Особенно его потребности в использовании памяти?

Самое главное, компилятор знает априори (то есть во время компиляции), насколько велики типы данных и сколько будет использоваться. Аргументы lhs и rhs равны sizeof(int), 4 байта каждый в 32-битной системе, 8 байтов каждый в 64-битной системе. Переменная result также sizeof(int). Допустим, мы находимся на 64-битной системе. Компилятор может сказать, что функция add() использует 8 bytes * 3 ints или всего 24 байта памяти.

Когда вызывается функция add(), аппаратный регистр называется указателем стека * 1043. * будет иметь адрес, который указывает на вершину стека. Чтобы выделить память, которую должна запустить функция add(), все, что нужно сделать для ввода кода функции, - это выполнить одну инструкцию на языке ассемблера, чтобы уменьшить значение регистра указателя стека на 24. При этом он создает хранилище настек для трех ints, по одному для lhs, rhs и result. Получение необходимого места в памяти за счет выполнения одной инструкции является огромным выигрышем с точки зрения скорости, поскольку отдельные инструкции, как правило, выполняются за один такт (1 миллиардная доля секунды при 1 ГГц ЦП).

Кроме того, изс точки зрения компилятора, он может создать карту для переменных, которая выглядит очень много, как индексирование массива:

lhs:     ((int *)stack_pointer_register)[0]
rhs:     ((int *)stack_pointer_register)[1]
result:  ((int *)stack_pointer_register)[2]

Опять же, все это очень быстро.

Когда add() Функция выхода должна быть очищена. Это делается путем вычитания 24 байтов из регистра указателя стека. Это похоже на вызов free(), но он использует только одну инструкцию процессора и занимает всего один тик. Это очень, очень быстро.


Теперь рассмотрим распределение на основе кучи. Это вступает в игру, когда мы не знаем a priori , сколько памяти нам понадобится (т.е. мы узнаем об этом только во время выполнения).

Рассмотрим эту функцию:

int addRandom(int count) {
    int numberOfBytesToAllocate = sizeof(int) * count;
    int *array = malloc(numberOfBytesToAllocate);
    int result = 0;

    if array != NULL {
        for (i = 0; i < count; ++i) {
            array[i] = (int) random();
            result += array[i];
        }

        free(array);
    }

    return result;
}

Обратите внимание, что функция addRandom() не знает во время компиляции, каким будет значение аргумента count. Из-за этого не имеет смысла пытаться определить array, как если бы мы помещали его в стек, например:

int array[count];

Если count огромен, это может привести к тому, что наш стек станет слишком большим и перезапишет другие программные сегменты. Когда происходит это переполнение стека , происходит сбой вашей программы (или хуже).

Итак, в случаях, когда мы не знаем, сколько памяти нам понадобится до времени выполнения, мы используем malloc(),Затем мы можем просто запросить количество байтов, которое нам нужно, когда нам это нужно, и malloc() проверит, может ли он продать столько байтов. Если это возможно, отлично, мы получаем его обратно, если нет, мы получаем указатель NULL, который сообщает нам, что вызов malloc() не выполнен. Примечательно, что программа не падает! Конечно, вы, как программист, можете решить, что ваша программа не может быть запущена в случае неудачного выделения ресурсов, но завершение, инициированное программистом, отличается от ложного сбоя.

Так что теперь мы должны вернуться, чтобы взглянуть наэффективность. Распределитель стека очень быстрый - одна инструкция для выделения, одна инструкция для освобождения, и это делается компилятором, но помните, что стек предназначен для таких вещей, как локальные переменные известного размера, поэтому он обычно довольно мал.

Распределитель кучи, с другой стороны, на несколько порядков медленнее. Он должен выполнить поиск в таблицах, чтобы увидеть, достаточно ли у него свободной памяти, чтобы иметь возможность продавать объем памяти, который требуется пользователю. Он должен обновить эти таблицы после продажи памяти, чтобы никто другой не мог использовать этот блок (эта бухгалтерия может потребовать, чтобы распределитель зарезервировал память для себя в дополнение к тому, что он планирует продать). Распределитель должен использовать стратегии блокировки, чтобы гарантировать, что он продает память потокобезопасным способом. И когда память, наконец, равна free() d, что происходит в разное время и, как правило, в непредсказуемом порядке, распределитель должен найти смежные блоки и соединить их вместе, чтобы восстановить фрагментацию кучи. Если это звучит так, как будто для выполнения всего этого потребуется более одной инструкции CPU, вы правы! Это очень сложно и требует времени.

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

Надеюсь, это поможет ответить на некоторые ваши вопросы. Пожалуйста, дайте мне знать, если вы хотите получить разъяснения по любому из вышеперечисленных вопросов.

...