Programowanie wydajnościowe w języku Java, część 2: Koszt przesyłania

W tym drugim artykule z naszej serii poświęconej wydajności Javy skupiamy się na rzutowaniu - co to jest, ile kosztuje i jak (czasami) możemy tego uniknąć. W tym miesiącu zaczynamy od szybkiego przeglądu podstaw klas, obiektów i odniesień, a następnie przyjrzymy się kilku hardkorowym statystykom wydajności (na pasku bocznym, aby nie urazić wrażliwego!) rodzaje operacji, które mogą powodować niestrawność w wirtualnej maszynie języka Java (JVM). Na koniec dogłębnie przyjrzymy się, jak możemy uniknąć typowych efektów strukturyzujących klasy, które mogą powodować rzucanie.

Programowanie wydajnościowe w języku Java: przeczytaj całą serię!

  • Część 1. Dowiedz się, jak zmniejszyć obciążenie programu i poprawić wydajność, kontrolując tworzenie obiektów i usuwanie elementów bezużytecznych
  • Część 2. Zredukuj narzuty i błędy wykonania dzięki bezpiecznemu kodowi
  • Część 3. Zobacz, jak kolekcje alternatywnych rozwiązań mierzą się pod względem wydajności i dowiedz się, jak najlepiej wykorzystać każdy typ

Typy obiektów i referencji w Javie

W zeszłym miesiącu omawialiśmy podstawowe rozróżnienie między typami prymitywnymi a obiektami w Javie. Zarówno liczba typów pierwotnych, jak i relacje między nimi (zwłaszcza konwersje między typami) są ustalone przez definicję języka. Z drugiej strony obiekty mają nieograniczone typy i mogą być powiązane z dowolną liczbą innych typów.

Każda definicja klasy w programie Java definiuje nowy typ obiektu. Obejmuje to wszystkie klasy z bibliotek Java, więc dowolny program może używać setek, a nawet tysięcy różnych typów obiektów. Kilka z tych typów jest określonych w definicji języka Java jako mające specjalne zastosowania lub obsługę (na przykład użycie java.lang.StringBufferdo java.lang.Stringoperacji łączenia). Oprócz tych kilku wyjątków wszystkie typy są jednak zasadniczo traktowane tak samo przez kompilator języka Java i maszynę JVM używaną do wykonywania programu.

Jeśli definicja klasy nie określa (za pomocą extendsklauzuli w nagłówku definicji klasy) innej klasy jako nadrzędnej lub nadklasy, to niejawnie rozszerza java.lang.Objectklasę. Oznacza to, że każda klasa ostatecznie rozszerza się java.lang.Object, bezpośrednio lub poprzez sekwencję jednego lub więcej poziomów klas nadrzędnych.

Same obiekty są zawsze instancjami klas, a typ obiektu to klasa, której jest instancją. Jednak w Javie nigdy nie mamy do czynienia bezpośrednio z obiektami; pracujemy z odniesieniami do obiektów. Na przykład wiersz:

 java.awt.Component myComponent; 

nie tworzy java.awt.Componentobiektu; tworzy zmienną referencyjną typu java.lang.Component. Mimo że odwołania mają typy tak samo jak obiekty, nie ma dokładnego dopasowania między typami odwołań i obiektów - wartością odniesienia może być nullobiekt tego samego typu co odniesienie lub obiekt dowolnej podklasy (tj. Klasa potomna od) typ odwołania. W tym konkretnym przypadku java.awt.Componentjest to klasa abstrakcyjna, więc wiemy, że nigdy nie może istnieć obiekt tego samego typu, co nasza referencja, ale z pewnością mogą istnieć obiekty podklas tego typu referencyjnego.

Polimorfizm i odlewanie

Rodzaj odniesienia określa sposób odwołuje przedmiot - to znaczy, że obiekt, który jest wartością odniesienia - mogą być używane. Na przykład w powyższym przykładzie kod używający myComponentmoże wywołać dowolną z metod zdefiniowanych przez klasę java.awt.Componentlub dowolną z jej nadklas w obiekcie, do którego się odwołuje.

Jednak metoda faktycznie wykonywana przez wywołanie jest określana nie przez typ samego odwołania, ale raczej przez typ obiektu, do którego się odwołuje. Jest to podstawowa zasada polimorfizmu - podklasy mogą przesłonić metody zdefiniowane w klasie nadrzędnej w celu zaimplementowania innego zachowania. W przypadku naszej przykładowej zmiennej, jeśli obiekt, do którego się odwołujemy, był w rzeczywistości instancją java.awt.Button, zmiana stanu wynikająca z setLabel("Push Me")wywołania byłaby inna niż ta wynikająca z wystąpienia obiektu, do którego się odwołujemy java.awt.Label.

Oprócz definicji klas programy Java używają również definicji interfejsów. Różnica między interfejsem a klasą polega na tym, że interfejs określa tylko zestaw zachowań (i, w niektórych przypadkach, stałe), podczas gdy klasa definiuje implementację. Ponieważ interfejsy nie definiują implementacji, obiekty nigdy nie mogą być instancjami interfejsu. Mogą to być jednak wystąpienia klas, które implementują interfejs. Odniesienia mogą mieć typy interfejsów, w którym to przypadku obiekty, do których istnieją odwołania, mogą być instancjami dowolnej klasy, która implementuje interfejs (bezpośrednio lub za pośrednictwem jakiejś klasy nadrzędnej).

Rzutowanie służy do konwersji między typami - w szczególności między typami referencyjnymi, dla typu operacji rzutowania, który nas tutaj interesuje. Operacje upcast (zwane także konwersjami rozszerzającymi w specyfikacji języka Java) konwertują odwołanie do podklasy na odwołanie do klasy nadrzędnej. Ta operacja rzutowania jest zwykle automatyczna, ponieważ jest zawsze bezpieczna i może być zaimplementowana bezpośrednio przez kompilator.

Operacje downcast (zwane także konwersjami zawężającymi w specyfikacji języka Java) konwertują odwołanie do klasy nadrzędnej na odwołanie do podklasy. Ta operacja rzutowania generuje obciążenie związane z wykonywaniem, ponieważ Java wymaga sprawdzenia rzutowania w czasie wykonywania, aby upewnić się, że jest poprawny. Jeśli obiekt, do którego istnieje odwołanie, nie jest instancją typu docelowego dla rzutowania ani podklasy tego typu, próba rzutowania nie jest dozwolona i musi zostać wygenerowana java.lang.ClassCastException.

instanceofOperator Java pozwala na ustalenie, czy konkretna operacja odlewania jest zabronione bez faktycznie próbą operację. Ponieważ koszt wydajności czeku jest znacznie mniejszy niż koszt wyjątku wygenerowanego przez niedozwoloną próbę rzutowania, ogólnie rozsądnie jest użyć instanceoftestu za każdym razem, gdy nie jesteś pewien, czy typ odwołania jest taki, jaki chciałbyś, aby był . Jednak zanim to zrobisz, powinieneś upewnić się, że masz rozsądny sposób radzenia sobie z odwołaniami niechcianego typu - w przeciwnym razie równie dobrze możesz po prostu pozwolić na wyrzucenie wyjątku i obsłużyć go na wyższym poziomie w swoim kodzie.

Rzucając ostrożność na wiatry

Casting pozwala na użycie programowania ogólnego w Javie, gdzie kod jest napisany tak, aby działał ze wszystkimi obiektami klas wywodzących się z jakiejś klasy bazowej (często java.lang.Objectw przypadku klas narzędziowych). Jednak stosowanie odlewów powoduje wyjątkowy zestaw problemów. W następnej sekcji przyjrzymy się wpływowi na wydajność, ale najpierw rozważmy wpływ na sam kod. Oto przykład użycia ogólnej java.lang.Vectorklasy kolekcji:

prywatne Vector someNumbers; ... public void doSomething () {... int n = ... Integer number = (Integer) someNumbers.elementAt (n); ...}

Ten kod przedstawia potencjalne problemy pod względem przejrzystości i łatwości konserwacji. Gdyby ktoś inny niż pierwotny programista zmodyfikował kod w pewnym momencie, mógłby rozsądnie pomyśleć, że mógłby dodać java.lang.Doubledo someNumberskolekcji, ponieważ jest to podklasa java.lang.Number. Wszystko by się dobrze skompilowało, gdyby spróbował tego, ale w jakimś nieokreślonym momencie wykonania prawdopodobnie zostałby java.lang.ClassCastExceptionrzucony, gdy próba rzucenia na a java.lang.Integerzostała wykonana dla jego wartości dodanej.

Problem polega na tym, że użycie rzutowania omija testy bezpieczeństwa wbudowane w kompilator Javy; programista kończy polowanie na błędy podczas wykonywania, ponieważ kompilator ich nie wychwyci. Nie jest to katastrofalne samo w sobie, ale ten typ błędu użytkowania często ukrywa się całkiem sprytnie podczas testowania kodu, tylko po to, aby ujawnić się, gdy program jest wprowadzany do produkcji.

Nic dziwnego, że obsługa techniki, która pozwoliłaby kompilatorowi na wykrycie tego typu błędu użytkowania, jest jednym z najczęściej żądanych ulepszeń języka Java. W procesie społeczności Java trwa projekt, który bada dodanie tylko tego wsparcia: numer projektu JSR-000014, Dodaj typy ogólne do języka programowania Java (zobacz sekcję Zasoby poniżej, aby uzyskać więcej informacji). W dalszej części tego artykułu, w przyszłym miesiącu przyjrzymy się temu projektowi bardziej szczegółowo i omówimy, w jaki sposób może on pomóc i gdzie prawdopodobnie sprawi, że będziemy chcieli więcej.

Problem z wydajnością

Od dawna wiadomo, że rzutowanie może mieć negatywny wpływ na wydajność w Javie i że można poprawić wydajność, minimalizując rzutowanie w często używanym kodzie. Wywołania metod, zwłaszcza wywołania przez interfejsy, są również często wymieniane jako potencjalne wąskie gardła wydajności. Obecna generacja maszyn JVM przeszła jednak długą drogę od swoich poprzedników i warto sprawdzić, jak dobrze te zasady sprawdzają się dzisiaj.

Na potrzeby tego artykułu opracowałem serię testów, aby zobaczyć, jak ważne są te czynniki dla wydajności obecnych maszyn JVM. Wyniki testu są podsumowane w dwóch tabelach na pasku bocznym, Tabela 1 przedstawia narzut wywołania metody, a Tabela 2 narzut rzutowania. Pełny kod źródłowy programu testowego jest również dostępny online (zobacz sekcję Zasoby poniżej, aby uzyskać więcej informacji).

Podsumowując te wnioski dla czytelników, którzy nie chcą przedzierać się przez szczegóły w tabelach, niektóre typy wywołań metod i rzutów są nadal dość drogie, w niektórych przypadkach trwają prawie tak długo, jak zwykła alokacja obiektów. Tam, gdzie to możliwe, tego typu operacji należy unikać w kodzie, który wymaga optymalizacji pod kątem wydajności.

W szczególności wywołania przesłoniętych metod (metody, które są nadpisywane w dowolnej załadowanej klasie, a nie tylko rzeczywistej klasie obiektu) i wywołania przez interfejsy są znacznie bardziej kosztowne niż proste wywołania metod. Użyta w teście wersja beta HotSpot Server JVM 2.0 może nawet przekonwertować wiele prostych wywołań metod na kod wbudowany, unikając narzutów związanych z takimi operacjami. Jednak HotSpot wykazuje najgorszą wydajność spośród testowanych maszyn JVM pod względem metod zastępowanych i wywołań przez interfejsy.

W przypadku przesyłania (oczywiście downcastingu) przetestowane maszyny JVM generalnie utrzymują wydajność na rozsądnym poziomie. HotSpot wykonuje wyjątkową pracę w większości testów porównawczych i, podobnie jak w przypadku wywołań metod, w wielu prostych przypadkach jest w stanie prawie całkowicie wyeliminować narzut rzucania. W bardziej skomplikowanych sytuacjach, takich jak rzutowanie, po którym następują wywołania nadpisanych metod, wszystkie przetestowane maszyny JVM wykazują zauważalne obniżenie wydajności.

Testowana wersja HotSpot wykazała się również wyjątkowo niską wydajnością, gdy obiekt był rzucany kolejno na różne typy referencyjne (zamiast zawsze być rzutowany na ten sam typ celu). Taka sytuacja regularnie pojawia się w bibliotekach takich jak Swing, które używają głębokiej hierarchii klas.

In most cases, the overhead of both method calls and casting is small in comparison with the object-allocation times looked at in last month's article. However, these operations will often be used far more frequently than object allocations, so they can still be a significant source of performance problems.

In the remainder of this article, we'll discuss some specific techniques for reducing the need for casting in your code. Specifically, we'll look at how casting often arises from the way subclasses interact with base classes, and explore some techniques for eliminating this type of casting. Next month, in the second part of this look at casting, we'll consider another common cause of casting, the use of generic collections.

Base classes and casting

There are several common uses of casting in Java programs. For instance, casting is often used for the generic handling of some functionality in a base class that may be extended by a number of subclasses. The following code shows a somewhat contrived illustration of this usage:

 // simple base class with subclasses public abstract class BaseWidget { ... } public class SubWidget extends BaseWidget { ... public void doSubWidgetSomething() { ... } } ... // base class with subclasses, using the prior set of classes public abstract class BaseGorph { // the Widget associated with this Gorph private BaseWidget myWidget; ... // set the Widget associated with this Gorph (only allowed for subclasses) protected void setWidget(BaseWidget widget) { myWidget = widget; } // get the Widget associated with this Gorph public BaseWidget getWidget() { return myWidget; } ... // return a Gorph with some relation to this Gorph // this will always be the same type as it's called on, but we can only // return an instance of our base class public abstract BaseGorph otherGorph() { ... } } // Gorph subclass using a Widget subclass public class SubGorph extends BaseGorph { // return a Gorph with some relation to this Gorph public BaseGorph otherGorph() { ... } ... public void anyMethod() { ... // set the Widget we're using SubWidget widget = ... setWidget(widget); ... // use our Widget ((SubWidget)getWidget()).doSubWidgetSomething(); ... // use our otherGorph SubGorph other = (SubGorph) otherGorph(); ... } }