В те времена, когда все виртуальные машины Java представляли собой интерпретаторы байт-кода, эта технология вызывала справедливые нарекания за свою низкую производительность. Но по прошествии некоторого времени на рынке появились хорошие компиляторы just-in-time (JIT), и сегодня тесты показывают, что по быстродействию Java во многих областях практически сравнялась с C++. Наметившиеся тенденции позволяют надеяться на то, что уже в самое ближайшее время Java не уступит в скорости C++.

Как соотносится производительность приложений Java и аналогичных программ на C++, максимально оптимизированных по критерию быстродействия? Думаю, что пользователям было бы интересно ознакомиться как с результатами теоретического сравнения, так и с выводами, сделанными на основе анализа тестовых программ и реальных приложений. Большинство аналитиков, не желая углубляться в хитросплетения сложных технологий, с ходу заявляют, что Java всегда будет уступать в производительности другим языкам по причине многоплатформенности Java. Такие утверждения можно встретить во многих статьях, посвященных Java и сетевым компьютерам.

Мы решили провести самостоятельное исследование и выяснить, в чем Java уступает C++ (специалисты любят противопоставлять именно эти два языка). Были изучены компоненты архитектуры Java и определены сравнительные характеристики быстродействия идентичных программ, написанных на Java и на C++.

Мы готовились к тому, что программы Java будут отставать по всем показателям, хотя и не верили, что C++ окажется в несколько раз быстрее. К большому нашему удивлению, какой-либо разницы в производительности практически не ощущалось. В некоторых случаях скорость приложений Java действительно значительно уступала этому показателю у C++, что, впрочем, легко объясняется наличием строгой модели безопасности и издержками технологии "сборки мусора".

Любое повышение производительности какого-либо языка следует рассматривать в строго определенном контексте. С момента появления Java прошло уже немало времени, и это время не было потрачено впустую. Неслучайно сегодня по уровню быстродействия этогот язык в большинстве случаев сравним с C++ (а C++ славится своей высокой скоростью).

КАК ПРОВОДИЛОСЬ ТЕСТИРОВАНИЕ

Тесты производительности были разбиты на четыре функциональные группы:

  • загрузка исполняемого кода;
  • выполнение программных инструкций;
  • распределение памяти;
  • доступ к системным ресурсам.

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

    Picture_1
    Рисунок 1.
    Выполнение задач, относящихся к четырем функциональным группам, программой на C++ в ОС Windows NT.
    Picture_2
    Рисунок 2.
    Выполнение задач, относящихся к четырем функциональным группам, в архитектуре Java.

    Разбиение на функциональные группы упрощает теоретическое сравнение быстродействия программ, написанных на C++ и Java. Кроме того, при оценке производительности программ, относящихся к различным группам, мы постарались использовать разные способы кодирования.

    Были разработаны три следующие тестовые программы.

    1. Тест основных возможностей (Simple Loop Test). Оценивалось время вызова функций, выполнения математических операций и операций преобразования типов.

    2. Тест распределения памяти (Memory Allocation Test). Определялась скорость выделения и освобождения памяти.

    3. Тест управления ресурсами (Bouncing Globes Test). Измерялась скорость анимации и управления ресурсами.

    Для измерения производительности была выбрана следующая среда.

  • Платформа: Windows NT 4.0 service pack 2
  • Аппаратная конфигурация: Pentium Pro 200, 128 MБ ОЗУ
  • Программное обеспечение: Visual C++ 5.0 и Sun JDK 1.1.5.

    В данной статье под платформой понимается комбинация процессора и ОС. Например, платформой можно считать комбинацию ОС Windows NT и процессора Intel, ОС Linux и процессора Intel или же ОС Linux и процессора Digital Alpha.

    ЗАГРУЗКА ИСПОЛНЯЕМОГО КОДА

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

    Загрузка исполняемого кода: Java против C++

    Если программа находится на локальном диске, загрузка даже сложного приложения не займет много времени. Если же программа хранится на Web-узле в Internet или в корпоративной сети intranet, размер исполняемого кода может существенно повлиять на производительность. Программы и ресурсы Java занимают значительно меньше места на диске по сравнению с программами C++ и загружаются из Internet и intranet гораздо быстрее. При определении скорости загрузки необходимо учитывать размер исполняемого кода и возможность избирательной загрузки.

    Размер исполняемого кода

    Исполняемые модули Windows NT, написанные на C++, занимают на диске значительно больше места, чем исполняемые модули Java. На размеры исполняемого кода оказывают влияние три фактора:

    Во-первых, двоичный формат исполняемого кода C++ увеличивает его размеры по сравнению с кодом Java почти в два раза.

    Во-вторых, технология Java предусматривает активное использование ряда библиотек, содержащих математические и сетевые функции, классы для работы с составными элементами и графикой и так далее. В виртуальные машины Java (JVM) встроена поддержка этих библиотек. В C++ же, напротив, определяются интерфейсы прикладных программ (API), позволяющие разработчику унифицировать доступ к большинству функций. К сожалению, если программист захочет использовать какие-либо специальные функции, не имея доступа к базовому API C++, ему придется самому реализовывать поддержку библиотек для своей программы. Включение в программу библиотечных функций может удвоить или даже утроить размеры конечного кода.

    В третьих, в комплект Java входят специальные библиотеки, позволяющие работать с графическими и звуковыми файлами, записанными в сжатом формате. Поддерживаются, в частности, графические форматы Joint Photographic Expert Group (JPEG) и Graphics Interchange Format (GIF), а также аудиоформат AU. Средства разработки программ на C++ для Windows NT поддерживают только несжатые форматы: bitmap (BMP) для изображений и wave (WAV) для аудиофайлов. Сжатие дает возможность на порядок уменьшить размеры графических файлов, и приблизительно в три раза - размеры аудиофайлов. Если вы хотите работать с другими форматами графических и звуковых файлов, можно воспользоваться дополнительными библиотеками классов.

    Избирательная загрузка

    Загрузчики программ, написанных на C++, перед началом выполнения должны полностью загрузить исполняемый файл. В среде Win32 имеется два способа связи с библиотеками DLL: статический и динамический. Статически связанные DLL (используемые в большинстве случаев) загружаются до начала выполнения программы. В случае динамического связывания библиотеки DLL загружаются по запросу. Однако такой вариант гораздо менее популярен, поскольку написание кода динамической загрузки и обращения к библиотечным функциям требует от разработчика значительных усилий.

    Кроме того, не существует способов проверки целостности динамически связанной DLL на этапе выполнения. Это означает, что без аварийного завершения нельзя выяснить, изменялась ли DLL в процессе выполнения программы. Наконец, если программа будет загружаться с удаленного диска, необходимо предварительно скопировать библиотеки на локальный носитель. Реализация такой процедуры в автоматическом режиме потребует дополнительных затрат на программирование.

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

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

    Загрузка исполняемого кода реальных программ

    В таблице 1 представлены размеры протестированных программ и их ресурсов. Огромная разница между размерами кода C++ и Java объясняется наличием библиотек, необходимых для выполнения программы C++. Объемы ресурсов отличаются вследствие того, что приложения Java работают со сжатыми файлами (в формате GIF), а программы C++ - с обычными файлами bitmap.

    Таблица 1

    Имя программы Размеры программы, Кбайт (C++/Java) Ресурсы, Кбайт (C++/Java)
    Simple Loop 46/3,9 -/-
    Memory Allocation 34/1,4 -/-
    Bouncing Globes 103/21 485/13

    ВЫПОЛНЕНИЕ ПРОГРАММНЫХ ИНТСТРУКЦИЙ

    После загрузки исполняемого кода процессор начинает выполнять программные инструкции. При традиционном программировании на C++ исполняемый файл содержит двоичные инструкции процессора выбранной платформы. Для переноса приложения на другую платформу разработчику потребуется создать новый исполняемый файл путем перекомпиляции исходного кода. Кроме того, особенности конкретной платформы каждый раз заставляют вносить определенные изменения в исходный код. Исполняемые же файлы, получаемые на выходе компилятора Java, представляют собой совокупность байт-кодов, которые не могут выполняться процессором без дополнительного преобразования в двоичный код. Это преобразование возлагается на JVM. Виртуальная машина Java может осуществлять преобразование двумя способами: с помощью интерпретатора байт-кода или посредством компилятора just-in-time (JIT).

    Выполнение программных инструкций: Java против C++

    У программ, написанных на Java, не слишком хорошая репутация. Их производительность заставляет вспомнит о старых JVM, для которых интерпретация байт-кода была единственным способом выполнения программных инструкций.

    Интерпретаторы байт-кода работали в несколько раз медленнее исполняемого кода программ C++, поскольку каждую инструкцию в ходе выполнения необходимо было преобразовать в двоичный код. Это, в свою очередь, приводило к неоправданным расходам. Приведем простейший пример. Каждый цикл состоит из набора многократно повторяющихся инструкций. При выполнении очередной итерации JVM снова и снова интерпретирует один и тот же байт-код, при этом на процедуру интерпретации уходит довольно много процессорного времени.

    Но методы, на которых базировалась работа старых JVM, уже уходят в прошлое. Большинство современных виртуальных машин Java оснащается JIT-компиляторами. Эти компиляторы транслируют и преобразуют в двоичный код сразу весь исходный файл. Таким образом, отпадает необходимость повторной трансляции каждой инструкции байт-кода.

    Производительность кода различных компиляторов

    Компиляторы C++ могут повысить скорость выполнения отдельных частей кода путем выявления неэффективных участков и их соответствующего преобразования. Этот процесс носит название оптимизации. К примеру, хороший компилятор способен распознать небрежность программиста и исключить из цикла "статические" вычисления. Под "статическими" вычислениями понимается выполнение в цикле определенной операции, результатом которой независимо от итерации всегда является константа.

    Распознав такую конструкцию, компилятор выводит ее за рамки цикла. Таким образом, значение константы вычисляется еще до входа в цикл и может использоваться внутри него, сокращая время выполнения и не изменяя логики программы. Этот тип оптимизации называется перемещением выражений. Для его выполнения компилятору необходимо сделать несколько проходов и изучить все инструкции цикла. В приведенном выше примере перемещения выражения все команды цикла должны быть тщательно проверены, и компилятор обязан убедиться в том, что при любой итерации значением выражения является константа.

    Виртуальная машина Java, не имеющая JIT-компилятора, последовательно интерпретирует каждую инструкцию и не может проводить подобную оптимизацию "на лету". Технология JIT-компилятора позволяет оптимизировать файл классов целиком.

    Таким образом, единственная причина, по которой обрабатываемая JIT-компилятором программа Java и приложение C++ работают с разной скоростью, - это необходимость первоначальной трансляции файла классов; она же, в свою очередь, зависит от типа проводимой оптимизации.

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

    На начальном этапе большинство компаний, разрабатывавших компиляторы JIT, пытались определять, какие классы следует компилировать, а какие - нет. Это зависело от того, как часто появляются классы в программе. Впоследствии многие производители отказались от такого решения, и компиляторы стали транслировать весь исходный код, поскольку общее время выполнения при этом практически не изменялось.

    Теория и практика

    Теоретически скорости выполнения байт-кода Java, обрабатываемого JIT-компилятором, и двоичного кода С++ не должны существенно различаться. На практикеже необходимо учитывать два фактора, оказывающих существенное влияние на производительность.

    Во-первых, одной и той же инструкции байт-кода могут соответствовать несколько различных последовательностей команд конкретного процессора. В результате выполнения этих последовательностей вы получите один и тот же результат, но время обработки при этом будет заметно отличаться. Если команды, получаемые на выходе JIT-компилятора и компилятора C++, требуют одинакового количества тактов процессора, быстродействие программ Java и C++ оказывается практически одинаковым. (В данном случае все зависит от того, насколько хорошо компилятор оптимизирует исполняемый код по критерию быстродействия.)

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

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

    Устранение неиспользуемого кода может привести к существенному повышению быстродействия программы, однако при этом значительно увеличивается и время компиляции. Необходимо учитывать, что появление неиспользуемого кода - это результат ошибок программиста. Опытные разработчики обязательно проверяют текст программы на наличие неиспользуемого кода, облегчая тем самым задачу компилятора.

    В общем случае в зависимости от выигрыша в производительности и временных затрат все виды оптимизации можно разделить на несколько уровней.

    Первый и второй уровень оптимизации как правило повышают быстродействие на 10-15 % при минимальных затратах.

    Третий уровень оптимизации позволяет увеличить производительность еще на 5 %, однако это обойдется значительно дороже.

    Примеры реальных программ

    В таблице 2 сравниваются результаты выполнения нескольких процедур Java и аналогичных программ, написанных на C++. Нетрудно заметить, что при отсутствии JIT-компилятора производительность Java в три-четыре раза уступает этому показателю для C++.

    Таблица 2

    Тест Описание Время выполнения программы C++, сек. Время выполнения программы Java (JIT-
    компилятор), сек.
    Время выполнения программы Java (интер-
    претатор байт-кода), сек.
    Целочисленное деление В цикле 10 тыс. раз выполнялась операция целочисленного деления. 1,8 1,8 4,8
    Неиспользуемый код В цикле 10 млн. раз встречалось неиспользуемое выражение 3,7 3,7 9,5
    Комбинация неиспользуемого кода и целочисленного деления В цикл была встроена одна исполняемая команда и 10 млн. раз неиспользуемых выражений 5,4 5,7 20
    Деление с плавающей точкой В цикле 10 млн. раз выполнялось деление с плавающей точкой 1,6 1,6 8,7
    Статический метод В цикле 10 млн. раз вызывался статический метод, реализующий целочисленное деление 1,8 1,8 6,0
    Компонентный метод В цикле 10 млн. раз вызывался компонентный метод, реализующий целочисленное деление 1,8 1,8 10
    Виртуальный компонентный метод* В данном тесте 10 млн. раз вызывался виртуальный компонентный метод, реализующий целочисленное деление 1,8 1,8 10
    Виртуальный компонентный метод с приведением типов и идентификацией типа в момент выполнения (RTTI) В данном тесте 10 млн. раз вызывался виртуальный метод класса, в котором выполнялась операция приведения типов 11 4,3 12
    Виртуальный компонентный метод с неправильным приведением типа и идентификацией типа в момент выполнения (RTTI) В данном тесте 10 млн. раз вызывался виртуальный метод класса, в котором выполнялась операция приведения типов -** -** -**
    Примечания:
    * Тест компонентного метода не вполне корректен, поскольку в Java все компоненты являются виртуальными.
    ** Аварийное завершение

    В полном соответствии с теоретическими исследованиями быстродействие программ Java, обрабатываемых JIT-компилятором, практически не отличается от этого показателя для процедур C++. Единственное исключение - выполнение операции приведения типов, где скорость C++ значительно превосходит скорость Java.

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

    В момент выполнения программы проверяется правильность преобразования родительского класса в дочерний (идентификация типа в момент выполнения - run-time type identification, RTTI). При эксплуатации сложных систем, в которых не реализованы методы RTTI, часто возникают ошибки. Это значительно снижает эффективность всей системы и подрывает доверие пользователей.

    Большинство программистов, пишущих на C++, отключают RTTI, чтобы повысить быстродействие. Технология Java не позволяет воспользоваться таким приемом, поскольку это может привести к нарушению системы безопасности. Наши тесты еще раз продемонстрировали, насколько методы RTTI замедляют скорость выполнения программы.

    РАСПРЕДЕЛЕНИЕ ПАМЯТИ

    Для хранения информации и выполнения вычислений программы должны эффективно управлять имеющейся памятью. После того как потребность в ранее выделенной области памяти отпадает, эта память должна быть освобождена и возвращена системе. Механизмы выделения памяти C++ и Java очень похожи. Что касается освобождения, здесь имеются принципиальные отличия. Программы C++ обязаны явно освобождать память. Очень часто при выполнении программ C++ возникают ошибки, вызванные тем, что программист забыл явно освободить память. Эта память "блокируется" и становится недоступной до завершения работы приложения.

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

    Распределение памяти: Java против C++

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

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

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

    Таким образом, Java и C++ используют различные механизмы управления памятью. Эффективность распределения памяти, характерная для Java, достигается за счет снижения производительности.

    Сложность реализации стратегии "сборки мусора" заключается в том, что механизм освобождения памяти должен самостоятельно выявить неиспользуемые объекты. Если программист твердо придерживается принципов объектно-ориентированной философии, процесс "сборки мусора" обойдется сравнительно недорого. В случае же использования процедурного подхода, сложность взаимосвязей значительно затруднит поиск неиспользуемых объектов.

    Примеры программ

    На выделение и освобождение памяти для 10 млн. 32-разрядных целых чисел программе на C++ потребовалось 0,812 сек., а программе на Java - 1,592 сек. На основании данного примера хорошо прослеживается снижение быстродействия Java. И все же несмотря на огромную дополнительную работу, выполненную "сборщиком мусора", производительность Java вполне соизмерима с производительностью C++.

    ДОСТУП К СИСТЕМНЫМ РЕСУРСАМ

    Помимо распределения памяти программе необходим доступ к другим системным службам, таким как вывод графических примитивов, обработка звука и управление окнами. Традиционные программы на C++ обращаются к системным функциям при помощи интерфейсов прикладных программ (API). Каждой платформе соответствует свой API, поэтому при переносе программы с одной платформы на другую разработчику приходится порой проводить существенные модификации.

    Программы Java обращаются к системным службам любой платформы при помощи единственного API. Каждая платформа имеет сходные интерфейсы, которые в ответ на обращения Java выделяют программе необходимые ресурсы.

    Доступ к системным ресурсам: Java против C++

    В общем случае преимущества, которые дает однотипное обращение к системным службам, оправдывают стоимость разработки специальных интерфейсов Java. Программы Java, вызывающие системные функции, работают так же быстро, как программы C++.

    Единственным исключением из этого правила является то, что средства, эквивалентные функциям API Java, имеются не на всех платформах. В частности, операционные системы компьютеров Macintosh не поддерживают потоки, поэтому соответствующие функции API Java невозможно задействовать на платформе Macintosh.

    Примеры программ

    Мы протестировали программу, разработанную известным специалистом в области анимации и мультимедиа Джеем Бартотом. Быстродействие реализующего ее алгоритм апплета Java ничем не отличалось от этого показателя программы, написанной при помощи средств Win32 SDK.

    Данный пример поможет служить подтверждением двух важнейших постулатов.

    Во-первых, производительность Java зависит от производительности JVM. Тестовая программа, работавшая под управлением Internet Explorer 4.0 и Netscape Communicator в среде Windows NT, не отличалась высокой скоростью выполнения. Объекты перемещались на экране неестественно, с высокой дискретностью. Однако стоило запустить браузеры в среде Windows 95, как скорость анимации стала вполне приемлемой.

    Разница в производительности объясняется огромной работой, проделанной с тем, чтобы оптимизировать быстродействие браузеров в среде Windows 95. Принципиальные архитектурные различия не позволяют обеспечить одинаковую производительность браузера в обеих операционных системах.

    Во-вторых, пакет JDK for Win32, разработанный корпорацией Sun, является общепринятым стандартом, который обязаны поддерживать все браузеры и JVM.

    КОМПЕТЕНТНОСТЬ РАЗРАБОТЧИКА - ГАРАНТИЯ ВЫСОКОЙ ПРОИЗВОДИТЕЛЬНОСТИ

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

    Проведенный нами анализ показал, что как с теоретической, так и с практической точки зрения значительной разницы в производительности приложений C++ и Java не наблюдается. А если такая разница и существует, программист, пишущий на Java, оказывается в гораздо более выгодном положении по сравнению с тем, кто работает на C++.

    Если сравнивать программирование на Java и на C++, оказывается, что у первого, безусловно, есть три основных преимущества, позволяющих сократить время разработки и повысить быстродействие сложных приложений.

    Остается лишь перечислить эти преимущества:

  • уменьшение размеров исполняемого кода и ресурсов;
  • наличие мощных и совместимых друг с другом библиотек для большинства платформ;
  • использование механизма "сборки мусора" для эффективного управления памятью.


    Кармен Манжьон (Carmine Mangione) - старший разработчик ПО в компании Netbot. Связаться с ней можно по адресу carmine.mangione@javaworld.com.