Теория языков 7

advertisement
Теория языков
программирования и
методы трансляции
Тема №7
Распределение памяти
Вопросы
До этого момента рассматривался, в основном, этап анализа
процесса компиляции. Сейчас рассмотрим этапа синтеза. Этап синтеза
тесно связан с генерацией кода, а важным и родственным этой теме
вопросом является распределение памяти. Связь между генерацией
кода и распределением памяти следующая; распределение памяти
обычно рассматривают как отдельную фазу процесса компиляции,
которую, вызывает генератор кода. В данной теме рассмотрим
следующие вопросы.
• Типы объектов, для которых нужно выделять память.
• Влияние ожидаемого времени существования объекта на
механизм выделения для него памяти.
• Распределение памяти для конкретных языковых характеристик.
• Основные используемые модели распределения памяти.
Распределение памяти.
Память.
Для начала обсуждения следует определить отличие объектов от их
значений. Переменная х является объектом, который в данное время может
иметь соотнесенное с ним значение, занимающее определенный объем
памяти. Память, выделенная значению х, имеет адрес, который позволяет
обращаться к х, причем адрес должен иметь следующие свойства.
• Быть достаточно большим (максимально необходимым), чтобы
вместить любое из значений, которое может принимать х.
• Быть доступным в течение всего времени существования х.
• Должна существовать возможность его выражения в такой форме,
чтобы генератор кода мог пользоваться адресом для получения доступа к
значению х во время выполнения программы.
Распределение памяти.
Память.
Относительно первого требования нужно отметить, что целые
значения обычно занимают меньше памяти, чем действительные, а
символьные значения могут занимать меньше памяти, чем целые. Для
обеспечения эффективного доступа не всегда имеет смысл уплотнять в
памяти значения до максимально возможной степени.
Память требуется для значений записей, массивов и указателей.
Память, требуемая для записей, обычно равна сумме объемов памяти,
требуемых для каждого поля записи. Для массивов требуется больше
памяти, чем для составляющих их элементов; избыток зависит от способа
хранения массива. Кроме того, некоторые языки допускают наличие у
массивов динамических границ, следовательно, в процессе компиляции
размер массива неизвестен и будет определен уже во время выполнения
программы. Объем памяти, необходимой для указателей, зависит от
реализации.
Распределение памяти.
Память.
В связи с тем, что память выделяется на все время жизни переменной,
возможны следующие ситуации.
• Время жизни переменной равно времени жизни программы. В
этом случае выделенная для переменной область памяти уже не
может быть освобождена. Такую память называют статической.
• Переменная объявляется в каком-то конкретном блоке, функции
или процедуре. В этом случае после завершения выполнения блока,
функции или процедуры выделенную для переменной память
можно освободить. Такую память называют динамической.
• Память может выделяться значениям в определенный момент
выполнения программы, не обязательно совпадающий с началом блока или
входом процедуры. Память выделяется в этот момент времени и существует
до тех пор, пока не будет освобождена – либо посредством
соответствующего механизма языка, либо после того, как просто станет
недоступной для программы. Сам момент освобождения памяти, в общем
случае, может не определяться при компиляции, а станет известным только
во время выполнения программы. Такую память называют глобальной.
Распределение памяти.
Память.
Требования к статической памяти полностью определяются во
время компиляции, так что необходимый объем может быть выделен.
Поскольку выделенную статическую память освободить невозможно,
общий объем такой памяти является суммой ее частных составляющих,
при этом какое-либо “совместное использование” этой памяти
невозможно.
Требования к динамической памяти программы сложнее,
поскольку память распределяется на входе функции (подпрограммы), а
освобождается после выполнения функции (подпрограммы). В этом
случае существует возможность совместного использования этой памяти
значениями, относящимися к различным функциям и т.д. Оказывается, что
управление этим типом памяти не настолько сложно, как может показаться
на первый взгляд, и его легко осуществить посредством механизма стека,
который увеличивается и уменьшается при выделении и освобождении
памяти.
Распределение памяти.
Память.
Распределение глобальной памяти осуществляется достаточно
просто: область пространства (куча (heap)) увеличивается настолько,
насколько это необходимо. Освобождение этой области памяти
осуществляется намного сложнее, поскольку данный процесс трудно связать
с процессом распределения памяти. Существует два основных вопроса,
связанных с распределением и освобождением глобальной памяти.
• Доступность памяти для освобождения определяется во время
выполнения программы, что неизбежно приводит к некоторого рода
служебным издержкам при выполнении программы.
• После освобождения некоторого участка памяти в куче возникают
чистые участки, которые обычно требуют сжатия для более
эффективного использования памяти.
Распределение памяти.
Память.
Отметим, что стек и куча могут удобно сосуществовать вместе, если
их увеличение происходит по направлению друг к другу. В этом случае
область статической памяти может размещаться на одном или другом конце
пространства памяти, как это изображено на рисунке. Вмешательство извне
потребуется только в том случае, когда взаимное расширение стека и кучи
приведет к их “встрече”, т.е. нехватке памяти. Обычно в подобном случае
определяется недоступное пространство кучи и происходит ее сжатие.
Распределение памяти.
Память.
При рассмотрении адресов переменных и т.д. следует отметить, что
иногда (например, при использовании статической памяти) адреса времени
выполнения известны во время компиляции. В то же время чаще имеем
обратную ситуацию, когда адреса времени выполнения должны
вычисляться, исходя из множества факторов, часть которых известна в
процессе компиляции, а часть неизвестна до начала выполнения программы.
В этих случаях аспекты адреса, известные при компиляции, называются
адресом времени компиляции.
Распределение памяти.
Память.
В языке С для переменных имеется четыре возможных класса
памяти: static, auto, extern и register. Для статических переменных память
выделяется на все время программы. Для переменных класса auto (класс по
умолчанию) память выделена до момента завершения работы составного
оператора, в котором были объявлены данные переменные. Таким образом,
более удобной для данных переменных является память в стеке. Для
переменных класса extern память выделяется в другом файле. Значения
переменных класса register хранятся в регистре, если компилятор способен
организовать это удобным образом, в противном случае такие переменные
эквивалентны переменным auto.
Распределение памяти.
Память.
Помимо памяти, необходимой переменным программы на С, с
помощью malloc можно выделить память для значений, к которым
обращаются посредством указателей, например:
p = malloc(sizeof(int));
В данном выражении выделяется достаточно памяти для целого
значения, а р является указателем на это значение. Эта память может
освобождаться после того, как ни одна переменная программы (в том
числе р) не будет указывать на данную область памяти. В то же время,
поскольку это невозможно определить в процессе компиляции, область,
выделяемая посредством malloc, обязательно должна располагаться в
куче.
Распределение памяти.
Статическая и динамическая память.
Ранние языки программирования, имели статическую память,
размер которой был известен во время компиляции. Выделенный объем
памяти уже не освобождался, поэтому применялась очень простая модель
распределения памяти – необходимая память выделялась от одного края
доступного пространства по направлению к другому. Более современные
языки, обычно имеют блочную структуру, что позволяет переменным,
объявленным в различных блоках, совместно использовать одну область
памяти. Удобными являются основанные на стеках модели распределения
памяти, которые позволяют повторно использовать ранее выделенную
память. Используемый в этих моделях стек времени выполнения подобен
рассмотренной таблице символов, но с одним важным отличием: стек
времени выполнения – это структура времени выполнения программы, а не
времени компиляции. В то же время, многие операции над таблицей
символов во время компиляции являются копиями операций над стеком
времени выполнения.
Распределение памяти.
Статическая и динамическая память.
Для иллюстрации выделения памяти в стеке времени выполнения
обратимся к схеме функции С, которая использовалась при изучении
таблицы символов. Часть стека, необходимую одной функции, называют
стековым фреймом (stack frame), и ниже показывается, как можно выделить
память для фрейма, соответствующего функции scopes.
void scopes()
{int a,b,c; /*уровень 0*/
{int a,b;
/*уровень la*/
}
{float c,d; /*уровень lb*/
{int m; /*уровень 2*/
…
}
}
}
Распределение памяти.
Статическая и динамическая память.
В контексте таблицы символов нас интересовала информация о
типах и хранении типов переменных, т.е. вопросы, относящиеся к периоду
компиляции. Во время выполнения программы важнее значения, а не типы,
и это отражается на структуре стека времени выполнения, который
запоминает значения так, как таблица символов запоминает типы. По мере
выполнения представленного фрагмента программы проследим, как может
выделяться память в стеке времени выполнения – во многом этот процесс
подобен изменению содержимого таблицы символов в период компиляции
программы.
Распределение памяти.
Статическая и динамическая память.
Изначально стек времени выполнения пуст.
После объявления а, b, с (уровень 0) стек выглядит следующим
образом.
Распределение памяти.
Статическая и динамическая память.
Здесь а представляет область памяти для хранения значения
переменной а и т.д. После объявления уровня 1 стек может выглядеть
так.
После прохождения уровня 1а во время выполнения программы
стек возвращается к предыдущему состоянию.
Распределение памяти.
Статическая и динамическая память.
В начале уровня 1b он может преобразоваться к такому виду
(слева).
Здесь для значений с и d типа float выделено вдвое больше
памяти, чем для значений а, b и с типа int. В начале уровня 2 стек
принимает вид (справа).
Распределение памяти.
Статическая и динамическая память.
После выхода с уровня 2 стек становится таким (слева).
После прохождения уровня 1b стек возвращается в состояние
(справа). Вновь становится пустым после завершения функции
scopes.
Распределение памяти.
Статическая и динамическая память.
Согласно данному выше описанию после завершения
выполнения составного оператора область выделенной ему памяти стека
просто освобождается. Для этого может использоваться массив
указателей, каждый элемент которого указывает на основание сегмента
стека, который соответствует выполняемому в данный момент
составному оператору.
Для определения адреса переменной по отношению к основанию
стекового фрейма необходимо всего лишь знать объем памяти,
занимаемый значениями каждой из переменных, расположенных в стеке
ниже рассматриваемой переменной; эта информация известна в процессе
компиляции.
На практике стековый фрейм может не расширяться и сжиматься
при входе и выходе из каждого составного оператора или блока, как это
было выше. Вместо этого при вызове каждой функции может выделяться
максимально необходимое пространство памяти для фрейма.
Распределение памяти.
Статическая и динамическая память.
Описанная модель достаточна для удовлетворения требований к
памяти одной простой функции, но не программ, содержащих множество
функций, которые могут вызывать друг друга. Таким образом, требуется
более общая модель. Поскольку рассматривается динамическая память, то
на любом этапе выполнения программы память необходима лишь тем
функциям, что используются в данный момент. Кроме того, выход из
функций будет происходить в порядке, противоположном порядку их
вызова, так что модель не будет сильно отличаться от уже рассмотренной.
Основное отличие заключается в том, что последовательность вызовов
функций, в общем случае, во время компиляции неизвестна.
Распределение памяти.
Статическая и динамическая память.
Рассмотрим следующий фрагмент программы на С.
main() {
first ();
second ();
}
first() {
second();
}
Как видно, функцию second() можно вызвать любым из двух
способов.
1. Непосредственно из main().
2. Из first(), которая вызывается из main().
Распределение памяти.
Статическая и динамическая память.
На рисунках изображены соответствующие стеки времени
выполнения. Через second() помечена область стека, соответствующая
функции second(), и т.д.
Распределение памяти.
Статическая и динамическая память.
Адрес переменной по отношению к основанию стекового
фрейма, в котором она хранится, известен в процессе компиляции. В то
же время расположение стекового фрейма по отношению к основанию
стека, в общем случае, во время компиляции неизвестно и должно
определяться уже во время выполнения программы. Программа на языке
С имеет одно характерное свойство: во время выполнения доступ к
переменным (не относится к переменным класса extern и глобальным
переменным) возможен только из простой функции (которая активна в
данный момент). Следовательно, если во время выполнения программы
имеется указатель на начало текущего стекового фрейма, то информации
о значении указателя и адреса переменной внутри секции стека (известен
во время компиляции) достаточно для нахождения адреса переменной по
отношению к основанию стека.
Распределение памяти.
Статическая и динамическая память.
Указатели на начало каждого стекового фрейма, которые
соответствуют активным в данный момент функциям, называются
множеством динамических указателей стека. После завершения
выполнения функции, соответствующей верхнему стековому фрейму,
управление возвращается функции, чей фрейм располагается ниже, при
этом становятся доступными любые ее переменные. Следовательно, для
каждого стекового фрейма, находящегося в данный момент в стеке,
необходимо
запоминать
значения
динамических
указателей,
указывающих на этот фрейм.
Распределение памяти.
Статическая и динамическая память.
Как показано на рисунке, это можно осуществить с помощью
массива указателей.
Распределение памяти.
Статическая и динамическая память.
Для поддержания массива динамических указателей необходимы
следующие действия времени выполнения программы.
• Включение начального адреса нового стекового фрейма в массив
указателей при вызове каждой новой функции.
• Удаление верхнего значения массива указателей каждый раз при
окончании работы с функцией, соответствующей покидаемому стековому
фрейму.
В качестве альтернативы вместо использования массива указателей
можно запоминать динамические указатели в самом стеке.
В языке С указатели на основания фреймов в стеке времени
выполнения требуются только для того, чтобы после прекращения работы с
вызванной функцией среда вызова могла быть создана заново. В Pascal, Ada и
многих других языках также возможен доступ к переменным, объявленным в
процедурах или функциях, которые статически вложены в текущую
процедуру.
Распределение памяти.
Статическая и динамическая память.
Рассмотрим для примера следующую схему программы на языке
Pascal.
program demo (output); var x, у: real;
procedure first; var c, d: integer;
procedure second;
var p, q: integer;
begin
..
end;
procedure third;
var m, n: integei
begin
..
end;
begin
second;
third
end;
begin
first;
end.
Распределение памяти.
Статическая и динамическая память.
В момент вызова процедуры second стек времени выполнения может
выглядеть подобно изображенному на рисунке.
Распределение памяти.
Статическая и динамическая память.
Чтобы облегчить доступ к переменным, объявленным во внешних
областях видимости, модель памяти Pascal, возможно, должна будет иметь
указатели (называемые статическими указателями) к каждому из
доступных в данный момент внешних блоков. Массив таких указателей
обычно называют дисплеем, и его вид подобен изображенному на рисунке.
Распределение памяти.
Статическая и динамическая память.
В то же время он не обязательно соответствует массиву указателей
ко всем процедурам, которые сейчас выполняются. Предположим,
например, что в процедуре third также имеется обращение к процедуре
second.
program demo (output); var x, у: real;
procedure first; var c, d: integer;
procedure second; var p, q: integer;
begin
..
end;
procedure third; var m, n: integer;
begin
second;
..
end;
begin
second;
third
end;
begin
first;
end.
Распределение памяти.
Статическая и динамическая память.
Ситуация непосредственно перед вызовом second из third
изображена на рисунке слева, а сразу же после вызова – на рисунке
справа.
Распределение памяти.
Статическая и динамическая память.
Из иллюстраций видно, что в дисплее содержатся только указатели
на блоки с переменными, доступными в данный момент, следовательно, по
одному указателю к каждому статическому уровню, к переменным
которого возможен доступ. Данная ситуация показана на рисунке справа,
где отсутствует возможность доступа к переменным third после вызова
second из third. Это связано с тем, что second и third располагаются
на одном статическом уровне
Распределение памяти.
Статическая и динамическая память.
В модели распределения памяти с использованием дисплея, если
функция (или процедура) объявлена на том же статическом уровне, что и
вызываемая в данный момент функция, происходит обновление значения
указателя на вершине дисплея; если же функция статически объявлена
внутри вызываемой в данный момент функции, то на дисплей поступает
новое значение. Подобное имеет место и при завершении работы функции.
После завершения функции возможно возвращение или к функции того же
статического уровня, или к функции вмещающего уровня. В первом случае
происходит обновление верхнего значения дисплея, а во втором – верхний
элемент дисплея удаляется.
Возможен еще один случай: функция вызывает саму себя –
рекурсивный вызов, возможный во многих языках. В этом случае
вызывающая среда и вызываемая среда статически находятся на одном
уровне, и верхний элемент дисплея должен обновляться при начале и
завершении каждого вызова. Чтобы восстановить элементы дисплея, можно
хранить значения динамических указателей в основании каждого стекового
фрейма.
Распределение памяти.
Адреса времени компиляции.
В общем случае, в процессе компиляции адреса переменных
неизвестны. Укажем некоторые причины, почему это происходит.
• Во время выполнения программы расположение стекового фрейма,
который соответствует конкретной функции или процедуре,
зависит от порядка вызова функций (процедур);
• В процессе компиляции значение индексов массива обычно
неизвестно и будет вычисляться при выполнении программы.
• Доступ к некоторым переменным осуществляется посредством
указателей, значения которых в процессе компиляции неизвестны.
Распределение памяти.
Адреса времени компиляции.
Хотя в процессе компиляции адреса неизвестны, часть информации
о них обычно имеется. Например, известны следующие параметры.
• Смещение простого значения относительно основания стекового
фрейма;
• Смещение начала массива относительно основания стекового
фрейма;
• Статическая глубина функции, в которой объявлена переменная.
Статическая глубина относится к языкам Pascal и Ada (в С такого
понятия нет).
В языке С адрес простой переменной в процессе компиляции
представляет собой смещение по отношению к основанию стекового
фрейма. Это же относится и к полю записи, так как поля записи всегда
запоминаются последовательно, и предполагается, что объем требуемой
памяти для каждого из полей известен. Для языка Pascal или Ada адрес
времени компиляции простой переменной или поля записи будет состоять из
пары: (номер уровня, офсет)
Распределение памяти.
Адреса времени компиляции.
Здесь номер уровня – номер статического уровня функции или
процедуры, в котором была объявлена переменная или запись, а термин
“офсет” употребляется с тем же значением, что и в языке С (смещение от
начала фрейма).
Для массивов со статическими границами (значение границ
известно в процессе компиляции) адрес элемента массива, в зависимости от
применяемого языка, можно также выразить через номер уровня и офсет
или просто через офсет. Смещение элемента массива по отношению к
основанию стекового фрейма состоит из двух частей.
• Смещение начала массива по отношению к основанию стекового
фрейма.
• Смещение элемента массива по отношению к началу массива.
Распределение памяти.
Адреса времени компиляции.
Для массивов со статическими границами значение первой части
известно в процессе компиляции, а второй, в общем случае, – нет, поскольку,
в процессе компиляции обычно неизвестно значение индексов массива.
При нахождении адресов элементов массива часть вычислений
осуществляется во время выполнения программы с использованием
информации, известной при компиляции. Как будет показано далее, объем
вычислений зависит от размерности массива. Проиллюстрируем сказанное с
помощью следующего примера на языке Pascal.
Рассмотрим объявление массива.
var table: array[1..10, 1..20] of integer;
Распределение памяти.
Адреса времени компиляции.
Элементы массива обычно записывают построчно или, точнее,
согласно лексикографическому порядку индексов. Например, значения
элементов приведенной таблицы будут занесены в память в следующем
порядке.
table[1,1],
table[2,1],
.
.
.
table[10,1],
table[1,2],...,
table[2,2],...,
table[10,2],...,
table[1,20],
table[2,20],
table[10,20],
Распределение памяти.
Адреса времени компиляции.
Адрес конкретного элемента массива вычисляется как смещение от
адреса первого элемента массива.
адрес(table[i,j]) = адрес(table[l1,l2]) + (u2 – l2 + 1)*(i – l1) + (j – l2)
Здесь l1, и u1 – нижняя и верхняя границы первого измерения и т.д.,
а каждый элемент массива предполагается размером в одну ячейку памяти.
В приведенном выше примере нижние границы в каждом случае равны 1, а
верхние — 10 и 20 соответственно.
Распределение памяти.
Адреса времени компиляции.
Для трехмерного массива arr3 объявленного как
var аrr3:
array [l1…u1,
l2…u2,
l3…u3]
обшая формула для адреса элемента
следующий вид.
of integer
массива arr3[i,j,k]
имеет
адрес(arr3[i,j,k]) = адрес(arr3[l1,l2,l3]) + (u2 – l2 + 1)*(u3 – l3 + 1)*(i – l1) +
(u3 – l3 + 1)*(j – l2) + (k – l3)
Выражение (ur – lr + 1) представляет число различных значений,
которые может принимать r-й индекс, т.е. (u3 – l3 + 1) – это число значений,
что может принимать третий индекс, а также расстояние между элементами
массива, которые отличаются на единицу во втором индексе.
Распределение памяти.
Адреса времени компиляции.
Подобным образом
(u2 – l2 + 1)*(u3 – l3 + 1)
представляет число различных пар значений, которые могут образовать
второй и третий индексы, а также расстояние между элементами массива,
которые отличаются на единицу в первом индексе. Расстояние между
элементами массива, которые отличаются на единицу в i-ом индексе,
называют шагом по i-ому индексу (ith stride). Таким образом, в приведенном
выше примере шаг по первому индексу равен
(u2 – l2 + 1)*(u3 – l3 + 1)
по второму и третьему – (u3 – l3 + 1) и 1 соответственно.
Распределение памяти.
Адреса времени компиляции.
Из приведенной выше формулы для нахождения смещения элемента
массива относительно адреса первого элемента массива понятно, что
вычисления становятся достаточно простыми, если известны шаги по
индексам. Например, для arr3 адрес элемента arr3[i,j,k] выражается
следующим образом.
адрес(arr3[i,j,k]) = адрес(arr3[l1,l2,l3]) + s1*(i – l1) + s2*(j – l2) + s3*(k – l3)
Здесь s1, s2, s3 – шаги по соответствующим индексам, равные
следующему,
s1 = (u2 – l2 + 1)*(u3 – l3 + 1)
s2 = (u3 – l3 + 1)
s3 = 1
Распределение памяти.
Адреса времени компиляции.
На рисунке показано применение шагов по индексам для
нахождения адреса элемента массива из следующего массива, объявленного
таким образом (язык Pascal).
var N: array [1..10, 1..10, 1..10] of integer;
Распределение памяти.
Адреса времени компиляции.
Для языков, в которых границы массива известны во время
компиляции, значения шагов по индексам могут вычисляться сразу же, что
сокращает количество вычислений времени выполнения программы при
каждом обращении к массиву. Дальше упростить приведенную формулу уже
невозможно, поскольку разность (i – l1), в общем случае, во время
компиляции неизвестна. Для языков с динамическими границами (до
выполнения программы они неизвестны) шаги по индексам можно найти
после объявления массива и занесения его в стек, что опять же уменьшит
количество вычислений, выполняемых при каждом обращении к массиву.
Хотя значения шагов по индексам в процессе компиляции могут быть
неизвестны, практически всегда будет известен объем памяти, которую
будут занимать шаги по индексам, и память для них может быть выделена в
процессе компиляции. В то же время память для самих элементов массива
может выделяться только при выполнении программы, поскольку при
компиляции значения границ могут быть неизвестны.
Распределение памяти.
Адреса времени компиляции.
Для рассмотрения динамических массивов требуется более общая
модель стека времени выполнения, чем рассмотренная ранее. В общем
случае неизвестно расположение начала массива в стековом фрейме.
Поэтому каждый стековый фрейм удобно разбить на две части:
статическую часть, в которой содержатся значения, известные во время
компиляции, и динамическую часть, содержащую значения, неизвестные в
процессе компиляции. Все значения динамической части можно будет
получить (с помощью указателей) из | значений статической части.
Распределение памяти.
Адреса времени компиляции.
Следовательно, в статической части фрейма будут содержаться
следующие значения.
• Все простые значения (типы integer, float и т.д.).
• Статические части массивов (границы, шаги по индексам, указатели
на элементы массива).
• Статические части записей (поля, размеры которых известны во
время компиляции).
• Указатели на глобальные значения – хотя глобальные значения будут
храниться не в стеке, а в куче.
Распределение памяти.
Адреса времени компиляции.
С другой стороны, в динамической части фрейма будут находиться
элементы массива. При использовании этой модели на практике даже
элементы массива со статическими границами будут храниться в
динамической части фрейма. Описанная более общая модель стекового
фрейма изображена на рисунке.
Распределение памяти.
Адреса времени компиляции.
В этой модели для доступа к элементам массива (по сравнению с
доступом к элементам, не входящим в массив) необходимы
дополнительный; указатель и офсет. Значение номера уровня фрейма дает
первый указатель с дисплея. К этому добавляется офсет указателя (в
статической части массива) относительно начала массива, кроме того, во
время выполнений программы данный указатель увеличивается, чтобы
представлять адрес конкретного элемента массива.
Распределение памяти.
Адреса времени компиляции.
В процессе компиляции адрес массива в целом – это просто уровень
и офсет, соответствующий началу статической части массива. Для
нахождения адреса во время выполнения требуются вычисления, общий вид
которых приведен ранее. Очевидно, что доступ к элементам массива
занимает много времени, в особенности для многомерных массивов. Это
время можно уменьшить, если производить вычисление шагов по индексам
только один раз. Для массивов с динамическими границами при выполнении
программы время также тратится на каждое обращение к дополнительному
указателю.
Распределение памяти.
Куча.
Куча используется для хранения значений, к которым может
потребоваться доступ от момента выделения для них памяти и до
завершения программы. Не существует механизма языка, подобного выходу
из блока или функции, который сделает область памяти недоступной. На
первый взгляд схема распределения для такой памяти должна выделять
память от одного конца линейного пространства до другого, пока свободная
память не будет распределена полностью. Может показаться, что при этом
никаких проблем с перераспределением или чрезмерным использованием
памяти возникнуть не должно. В то же время данный подход имеет
существенный недостаток, а именно: после первого полного распределения
памяти применение следующего оператора, например,
string = malloc(4);
который попытается выделить четыре бита памяти и вернуть указатель на
эту область, вызовет ошибку в программе.
Распределение памяти.
Куча.
Впрочем, прежде чем смириться с этим, следует вспомнить, что
область памяти может стать недоступной вследствие таких операций
программы, как переназначение указателей и т.д. Например, выделенное
ранее пространство может стать недоступным для переменной string
после следующего присваивания.
string = newstring;
В то же время данная операция позволяет получить доступ к
рассматриваемому пространству некоторой другой переменной. Рассмотрим
результат выполнения присваивания
stringl = string;
между двумя указанными выше операторами. Считая, что другие операторы,
связанные с данными, отсутствуют, подобные действия приведут к тому, что
переменной stringl будет доступно пространство, выделенное
оператором malloc.
Распределение памяти.
Куча.
Поскольку, в общем случае, в процессе компиляции неизвестно, как
будет выполняться программа, то при компиляции невозможно узнать, когда
станет недоступной память, выделенная оператором malloc. Это означает,
что код для восстановления области кучи не может быть сгенерирован в
процессе компиляции, хотя недоступными могут стать большие области,
выделенные в куче. Один из способов преодоления такой трудности
заключается в том, чтобы программисты (исходя из своих знаний о том, как
будет выполняться программа) предугадывали момент, когда память кучи
становится недоступной, и вводили в исходный код явные инструкции для
перераспределения памяти. Например, в С для освобождения области
памяти, отведенной переменной string, можно записать следующее.
free(string);
Распределение памяти.
Куча.
В то же время данный подход требует от программиста большого
профессионализма
и
ответственности.
Итак,
какие-либо
автоматизированные
методы
освобождения
памяти
применять
нежелательно. В языке Java предполагается, что ответственность за
освобождение недоступной памяти должна возлагаться на реализацию Java,
а не на программиста; таким образом, любая реализация Java должна иметь
соответствующие механизмы. Примечательно, что в отличие от ранее
описанных моделей пмяти, Java сохраняет в куче массивы. Все объекты
также хранятся в куче.
В связи с восстановлением недоступных областей памяти
существуют два возможных метода управления кучей.
• сборка мусора (garbage collection);
• использование счетчиков ссылок (use of reference counters).
Распределение памяти.
Куча.
Первый метод, пожалуй, является более популярным, но и более
необходимым. Преимущество этого подхода заключается в том, что до
полного распределения всего доступного пространства памяти не возникает
потребности в восстановлении любой его части. Вследствие этого во многих
случаях для сборки мусора времени вообще не требуется. Если (и когда)
сборка мусора все-таки требуется, этот процесс происходит в две фазы.
• Фаза маркировки, в которой (посредством введения значений в
битовую карту) помечается память кучи, доступная для переменных
программы.
• Фаза сжатия, в которой все доступное пространство сдвигается в
один конец кучи, а память, подлежащая повторному использванию,
образует непрерывный блок в другом конце кучи. При этом,
разумеется, следует аккуратно проверить, чтобы соответствующим
образом изменились все значения указателей.
Распределение памяти.
Куча.
Из этих двух фаз фаза маркировки наиболее интересна и допускает
меньше альтернативных способов реализации. Требуются некоторые
средства “маркировки” ячеек памяти, к которым при необходимости могут
обращаться переменные программы. Для этого может использоваться
битовая карта с достаточным числом битов для сопоставления с каждой
ячейкой кучи. Битовая карта не является частью кучи и располагается
отдельно от нее. Каждый бит в битовой карте может принимать одно из двух
значений.
0 – соответствующая ячейка памяти не доступна для переменных
программы.
1 – соответствующая ячейка памяти доступна для переменных программы.
Распределение памяти.
Куча.
В начале процесса сборки мусора все элементы битовой карты
имеют значение 0, а при выполнении алгоритма различным элементам
карты присваивается значение 1. В завершение сборки мусора значение 1
получат все элементы битовой карты, которые соответствуют ячейкам
памяти, доступным для переменных программы. Простой алгоритм сборки
мусора использует стек (называемый стеком сборки мусора) и заключается
в следующем.
Распределение памяти.
Куча.
Сборка мусора 1
1. Стек времени выполнения линейно просматривается, пока не будет
обнаружена переменная, указывающая на непомеченную ячейку кучи. Это
может быть или собственно переменная, которая является указателем (в
кучу), или компонент записи, который является указателем. В дальнейшем,
все ячейки кучи, на которые указывают подобные переменные,
маркируются посредством включения соответствующих бит в битовую
карту.
2. Некоторые ячейки, в свою очередь, могут быть указателями на
непомеченные ячейки кучи. В этом случае их адреса помещаются в стеке
сборки мусора.
Распределение памяти.
Куча.
3. Далее следуют адреса с верха стека сборки мусора или (если стек сборки
мусора пуст) адреса, содержащиеся в следующем указателе на стек времени
выполнения. Затем маркируются все непомеченные ячейки кучи, на
которые указывает куча, и их адреса помешаются в стек сборки мусора.
4. Третий шаг повторяется до тех пор, пока освободится стек сборки мусора,
и все указатели в стеке времени выполнения будут обработаны описанным
образом.
Поскольку на третьем шаге всегда маркируются непомеченные
ячейки, то, в конце концов, выполнение алгоритма прекратится.
Распределение памяти.
Куча.
Описанный выше алгоритм является наглядным, простым для
понимания и эффективным. Однако, у него имеется один существенный
недостаток – он нереальный, поскольку требует использования стека
произвольного размера в момент наибольшей загруженности памяти.
Другими словами, сборка мусора просто не будет инициирована.
Безусловно, никто не ожидает, что чистка памяти будет выполняться при
отсутствии пространства для работы. В то же время, поскольку для сборки
мусора требуется небольшой (и известный) объем памяти, то при нехватке
памяти ее можно инициировать в первую очередь. Фактически, существует
алгоритм сборки мусора с предельно малыми запросами рабочего
пространства.
Распределение памяти.
Куча.
Сборка мусора 2
1. Пометить все ячейки кучи, на которые прямо указывают значения из
стека времени выполнения.
2. Просмотреть кучу, начиная с низших адресов, чтобы найти первый
помеченный указатель, указывающий на непомеченную ячейку. Пометить
эту ячейку.
3. Продолжить просмотр кучи, помечая непомеченные ячейки, на которые
указывают помеченные ячейки. Выделить адрес ячейки с наименьшим
адресом, помеченным таким способом. Назвать этот адрес низшим.
4. Повторять шаги 2 и 3, уже начиная с низшего адреса, пока при просмотре
будет помечаться хотя бы одна ячейка. Поскольку число ячеек,
которые необходимо пометить, конечно, то, в конце концов, выполнение
алгоритма прекратится.
Распределение памяти.
Куча.
Помимо пространства, необходимого для битовой карты, алгоритму
также требуются три переменные, представляющие:
• текущую позицию при просмотре;
• ячейку, к которой идет обращение низший адрес, к которому должно
идти обращение при текущем просмотре.
В то же время, с точки зрения затрачиваемого времени этот
алгоритм может быть крайне неэффективным. В частности, это может
быть в том случае, когда в куче содержится много обратных указателей,
и это является ценой за неиспользование стека.
Распределение памяти.
Куча.
Компромиссом между двумя описанными алгоритмами будет
алгоритм, придерживающийся стратегии 1 при достаточно свободной
памяти к стратегии 2 – в противоположном случае. Например, если стек
достаточно большой, то алгоритм может использовать стек фиксированного
размера и придерживаться первой стратегии. Как только при увеличении
стека станет реальной угроза его переполнения, из стека может удаляться
одно значение. Удаленное таким образом нижнее значение стека
запоминается и используется для начала второй фазы алгоритма, которая во
многом будет подобна сборке мусора 2.
Распределение памяти.
Куча.
Еще в одном известном алгоритме куча рассматривается как
древовидная структура с указателями от вершины к основанию. Сборка
мусора начинается с вершины дерева и идет по направлению вниз. Вместо
использования стеков для запоминания указателей, требующих
последующей обработки, алгоритм использует указатели самого дерева,
временно обращая их для обеспечения пути возврата вверх по дереву. Этот
алгоритм эффективнее; и с точки зрения времени, и с точки зрения
требуемой памяти.
Распределение памяти.
Куча.
Другие схемы очистки памяти включают различные схемы сборки
мусора с учетом поколений (generational garbage collection), в которых
производится разделение:
• между глобальными объектами, которые существуют относительно
долго еще до инициации процесса сборки мусора, и память для которых
очищать не обязательно;
• локальными объектами, которые существуют меньшее время, и
память которых постоянно требуется возвращать в доступную область.
Очевидно, что такая схема уменьшает время сборки мусора и может
быть достаточно эффективной. В других схемах для уменьшения времени
сжатия кучи используются две глобальные области.
Распределение памяти.
Куча.
Какой бы метод сборки мусора не использовался, может случиться
так, что программа просто исчерпает доступную память и будет вынуждена
завершить работу, если только система не разрешит эту проблему каким-то
иным способом. Память программы может также ограничиваться за счет
сборки мусора, если при используемом алгоритме большая часть времени
уходит именно на чистку памяти – вскоре после завершения сборки мусора,
когда программа уже кажется готовой к продолжению работы, куча снова
переполняется, что вновь требует проведения очистки памяти. В такой
ситуации служебные издержки на проведение сборки мусора могут быть
очень значительными, и именно здесь будет уместным альтернативный
подход – использование счетчиков ссылок. Этот метод позволяет
(достаточно часто) заменить непредсказуемые издержки на сборку мусора
издержками постоянными и предсказуемыми.
Распределение памяти.
Куча.
При использовании счетчиков ссылок предпринимается попытка
очистить каждый элемент памяти кучи сразу же после прекращения
обращений к нему. Каждая ячейка памяти в куче имеет счетчик ссылок, в
котором фиксируется число значений, обращающихся к данной ячейке.
Появление каждой новой переменной, обращающейся к данной ячейке
увеличивает значение счетчика, а исчезновение ссылки уменьшает его когда
значение счетчика становится нулевым, ячейка может быть возвращена в
область свободной памяти для дальнейшего распределения
Распределение памяти.
Куча.
Этот метод удачен, но имеет некоторые ограничения.
• Не может очищаться память, которая связана со структурами данных,
подобными кольцевым спискам.
• Постоянные издержки, связанные с использованием счетчиков
ссылок, могут сильно уменьшать эффективность программ с предельно
малыми запросами относительно памяти.
Распределение памяти.
Куча.
В заключение отметим, что второй пункт противоречит принципу
Бауэра, который утверждает, что “простые программы” не должны платить
за неиспользование существующих дорогих характеристик языка.
Download