RSS

Java nie jest taka łatwa, jak się wydaje

Artykuł dostępny również w językach:
Liczba odsłon: 750

Wiele osób – szcze­gól­nie stu­den­ci in­for­ma­ty­ki „zmu­sza­ni” do nau­ki pro­gra­mo­wa­nia – uwa­ża, że pro­gra­mo­wa­nie w ję­zy­kach C i C++ jest nie­po­trzeb­nie skom­pli­ko­wa­ne, peł­ne prze­szkód i za­sa­dzek. Przera­że­ni wy­cie­ka­mi pa­mię­ci, ope­ra­to­ra­mi i szab­lo­na­mi, ucie­ka­ją w bez­piecz­ną ich zda­niem przy­stań ję­zy­ka Java, wo­ląc bu­do­wać opro­gra­mo­wa­nie na jej spo­koj­nych wo­dach. Szkoda tyl­ko, że nie zda­ją so­bie spra­wy, że i ta za­to­ka na­je­żo­na jest mi­na­mi.

Jedną z pod­sta­wo­wych pu­ła­pek czy­ha­ją­cych na po­cząt­ku­ją­ce­go pro­gra­mis­tę ję­zy­ka Java jest łat­wość two­rze­nia obiek­tów. Można bez opo­rów ko­rzy­stać z ope­ra­to­ra new, po­wo­łu­jąc do ist­nie­nia obiek­ty, któ­re w swo­im cyk­lu ży­cia two­rzą ko­lej­ne obiek­ty. Każda alo­ka­cja jest rea­li­zo­wa­na w eks­pre­so­wym tem­pie, a gdy pa­mięć nie jest po­trzeb­na, wra­ca do pu­li. Ten pe­łen dy­na­mizm prze­kła­da się na wszyst­kie struk­tu­ry da­nych: zmien­ne łań­cu­cho­we moż­na bez prze­szkód łą­czyć ze so­bą, bu­fo­ry ty­pu String­Bu­il­der do­sto­so­wu­ją swo­ją po­jem­ność do po­trzeb, a tab­li­ce ty­pu Array­List po­tra­fią prze­cho­wać do­kład­nie ty­le ele­men­tów, ile za­prag­nie­my.

Ktokol­wiek na­pi­sał w ję­zy­ku Java coś wię­cej, niż pro­gram ty­pu Hello World, po­wi­nien po­dej­rzli­wie spoj­rzeć na po­przed­ni aka­pit. Przecież za ca­łą me­cha­ni­ką ję­zy­ka Java stoi ma­szy­na wir­tu­al­na na­pi­sa­na w ję­zy­ku C++, któ­ra mu­si alo­ko­wać blo­ki pa­mię­ci o ściś­le okreś­lo­nym roz­mia­rze i nie mo­że ich swo­bod­nie po­więk­szać. Ponowne wy­ko­rzys­ta­nie zbęd­nej pa­mię­ci mo­że na­stą­pić do­pie­ro po naj­bliż­szym cyk­lu po­rząd­ko­wa­nia (ang. gar­ba­ge col­le­ction), a każ­de zwięk­sze­nie po­jem­noś­ci struk­tu­ry da­nych mu­si po­le­gać na pod­ję­ciu no­we­go blo­ku, prze­pi­sa­niu do nie­go sta­rych da­nych i zwol­nie­niu pa­mię­ci.

Niestety, ten as­pekt pro­gra­mo­wa­nia w ję­zy­ku Java ucie­ka nie­któ­rym po­cząt­ku­ją­cym pro­gra­mis­tom. Uważają oni, że moż­na cał­ko­wi­cie oder­wać się od prob­le­mów zwią­za­nych z alo­ka­cją pa­mię­ci i że ist­nie­je cu­dow­ny algo­rytm auto­ma­tycz­nie przy­dzie­la­ją­cy i zwal­nia­ją­cy pa­mięć. Progra­my two­rzo­ne zgod­nie z tą fi­lo­zo­fią zaj­mu­ją po­tem set­ki me­ga­baj­tów pa­mię­ci opera­cyj­nej i spo­rą część cza­su pro­ce­so­ra prze­zna­cza­ją na cyk­le po­rząd­ko­wa­nia ster­ty. Owszem, ję­zyk Java nie sprzy­ja pi­sa­niu pro­gra­mów o naj­wyż­szej wy­daj­noś­ci, jed­nak i w nim da się pi­sać oszczęd­nie i wy­daj­nie.

Problem w tym, że wy­ma­ga to co naj­mniej ty­le sa­mo, je­że­li nie wię­cej uwa­gi, niż pro­gra­mo­wa­nie w ję­zy­ku C czy C++. Są ku te­mu co naj­mniej dwa po­wo­dy. Po pierw­sze, roz­wią­za­nia wie­lu prob­le­mów są ukry­te przed pro­gra­mis­tą. Bez za­głę­bia­nia się w tekst źród­ło­wy bi­blio­tek Java lub szpe­ra­nia w za­so­bach Inter­ne­tu trud­no jest do­wie­dzieć się, ja­ka jest stan­dar­do­wa po­cząt­ko­wa po­jem­ność na przy­kład wspom­nia­nych kon­te­ne­rów ty­pu Array­ListString­Bu­il­der — a ma to pod­sta­wo­we zna­cze­nie w przy­pad­ku two­rze­nia du­żych zbio­rów da­nych. Po dru­gie, pro­gra­miś­ci częs­to za­po­mi­na­ją, że pro­gra­my pi­sa­ne w C czy C++ są częs­to apli­kac­ja­mi jed­no­sta­no­wi­sko­wy­mi. Tymcza­sem zna­ko­mi­ta więk­szość pro­gra­mów pi­sa­nych w ję­zy­ku Java to pro­gra­my wie­lo­do­stęp­ne, uru­cha­mia­ne w śro­do­wis­ku kon­te­ne­ra Java EE. Nieefek­tyw­ność, któ­ra ucho­dzi pła­zem przy jed­nym użyt­kow­ni­ku, za­bi­ja ser­wer ob­słu­gu­ją­cy ty­siąc klien­tów na­raz.

Oto garść rad, któ­re po­wi­nien so­bie przy­swoić każ­dy pro­gra­mis­ta ję­zy­ka Java. Oczywiś­cie nie wy­czer­pu­ją one prob­le­mu, jed­nak sta­no­wią do­bry po­czą­tek roz­wo­ju w za­kre­sie two­rze­nia efek­tyw­ne­go opro­gra­mo­wa­nia.

1. Nie twórz obiek­tów bez po­trze­by

Operator new to tyl­ko trzy zna­ki, a obiekt mo­że odejść szyb­ko w nie­pa­mięć. Nawet naj­prost­szy obiekt zaj­mu­je jed­nak 16 B ster­ty. Wystar­czy stwo­rzyć ty­siąc zbęd­nych obiek­tów, by w cią­gu ułam­ka se­kun­dy za­jąć 16 KiB. Przy ty­sią­cu użyt­kow­ni­ków da­je to 16 MiB za­ję­to­ści pa­mię­ci. Jeżeli ta­ka ilość pa­mię­ci jest po­trzeb­na tyl­ko dla­te­go, że uży­ta kla­sa adap­te­ra wy­ma­ga two­rze­nia jed­ne­go obiek­tu adap­te­ra na je­den obiekt adap­to­wa­ny (za­miast jed­ne­go bez­sta­no­we­go), jest to pa­mięć stra­co­na.

Co gor­sza, kon­struk­tor two­rzo­ne­go obiek­tu mo­że alo­ko­wać ko­lej­ne. Jeżeli ma­my wgląd w tekst źród­ło­wy kla­sy, mo­że­my to spraw­dzić i al­bo przed­się­wziąć kro­ki za­po­bie­ga­ją­ce nad­mier­ne­mu za­śmie­ce­niu ster­ty, al­bo przy­naj­mniej wziąć to pod uwa­gę przy wy­zna­cza­niu za­ję­to­ści pa­mię­ci. Jeżeli uży­wa­my bi­blio­te­ki do­star­cza­nej wy­łącz­nie w for­mie ko­du po­śred­nie­go, po­zos­ta­je nam do­ku­men­tac­ja lub de­kom­pi­la­cja.

2. Maksymal­nie wy­ko­rzy­stuj ty­py pros­te

Zmienne ty­pów pros­tych zaj­mu­ją po czte­ry lub osiem baj­tów i są two­rzo­ne na sto­sie. Pola ty­pów pros­tych po­więk­sza­ją obiekt o swój roz­miar. Zmienne i po­la ty­pów obiek­to­wych są two­rzo­ne na ster­cie, zaj­mu­ją co naj­mniej 16 baj­tów i wy­ma­ga­ją co naj­mniej jed­nej re­fe­ren­cji (a więc ko­lej­nych czte­rech lub oś­miu baj­tów).

Jeżeli ja­kąś in­for­mac­ję da się sku­tecz­nie prze­cho­wać w zmien­nej lub po­lu ty­pu pros­te­go, oszczęd­ność pa­mię­ci bę­dzie ol­brzy­mia. Szczególnie opła­ca się ko­rzy­stać z tab­lic ty­pów pros­tych: 1 024 licz­by ty­pu int za­pi­sa­ne w tab­li­cy zaj­mą 4 KiB pa­mię­ci, a w kon­te­ne­rze ty­pu Array­List po­nad 20 KiB. Dużo więk­sza bę­dzie też lo­kal­ność za­pi­su, a więc efek­tyw­ność wy­ko­rzys­ta­nia pa­mię­ci pod­ręcz­nej pro­ce­so­ra. Czasem opła­ca się wręcz stwo­rzyć nie­co zbyt po­jem­ną tab­li­cę, niż ko­rzy­stać z dyna­micz­ne­go kon­te­ne­ra da­nych.

3. Minima­li­zuj re­alo­ka­cje

W in­for­ma­ty­ce nie ma magii. To, co nie­doś­wiad­czo­ny pro­gra­mis­ta mo­że uwa­żać za ma­gię, jest co naj­wy­żej wy­so­kim po­zio­mem ab­strak­cji. W przy­pad­ku kon­te­ne­rów da­nych ab­strak­cja kry­je się w al­go­ryt­mie re­alo­ku­ją­cym pa­mięć.

Kontener kla­sy Array­List ma po­cząt­ko­wą po­jem­ność rów­ną 10. To roz­miar właś­ci­wy, je­że­li ko­lek­cja da­nych bę­dzie mia­ła tyl­ko kil­ka ele­men­tów, ale du­żo za ma­ło, je­że­li chce­my do­dać choć­by tyl­ko sto po­zyc­ji. Pojemność tab­li­cy prze­cho­wu­ją­cej re­fe­ren­cje do obiek­tów bę­dzie ros­ła o mniej wię­cej 50%, za­tem ko­lej­ne re­alo­ka­cje bę­dą za­cho­dzi­ły po do­da­niu 10, 15, 22, 33, 50 i 75 ele­men­tów (mniej wię­cej; nie łap­cie mnie za licz­by). Jeżeli z gó­ry zna­my roz­miar ko­lek­cji, wy­star­czy wy­wo­łać kon­struk­tor pa­ra­met­rycz­ny Array­List(100), by od ra­zu przy­go­to­wać miej­sce na wszyst­kie ele­men­ty i spro­wa­dzić zło­żo­ność obli­cze­nio­wą do­da­wa­nia no­we­go ele­men­tu do O(1).

Podobnie jest w przy­pad­ku sca­la­nia na­pi­sów. Kontener String­Bu­il­der do­myśl­nie przy­go­to­wu­je się do prze­cho­wy­wa­nia 16 zna­ków. Oznacza to, że przez pierw­szych kil­ka chwil prak­tycz­nie każ­da ope­rac­ja ap­pend() bę­dzie po­wo­do­wa­ła re­alo­ka­cję pa­mię­ci. Operacje aryt­me­tycz­ne są szyb­kie, a re­alo­ka­cje pa­mię­ci są wol­ne, za­tem bar­dziej opła­ca się po­li­czyć naj­pierw prze­wi­dy­wa­ny roz­miar na­pi­su i wy­wo­łać kon­struk­tor pa­ra­met­rycz­ny kla­sy String­Bu­il­der, niż zda­wać się na efek­tyw­ność algo­ryt­mu re­alo­ka­cji pa­mię­ci.

Warto też pa­mię­tać, że kon­te­ne­ry nie od­da­ją z włas­nej wo­li raz przy­dzie­lo­nej pa­mię­ci. Na przy­kład, kon­te­ner Array­List zmniej­szy swo­ją po­jem­ność do­pie­ro w mo­men­cie wy­wo­ła­nia me­to­dy trimToSize(). Operacje re­move() czy clear() co naj­wy­żej usu­ną re­fe­ren­cje do prze­cho­wy­wa­nych ele­men­tów. Warto jed­nak krea­tyw­nie wy­ko­rzys­tać tę ce­chę i – o ile to moż­li­we – wie­lo­krot­nie wy­ko­rzys­ty­wać te sa­me kon­te­ne­ry do prze­cho­wy­wa­nia ko­lej­nych ko­lek­cji da­nych o po­dob­nej obję­toś­ci.

Przy okaz­ji: String­Buf­fer to syn­chro­ni­zo­wa­na od­mia­na String­Bu­il­der, a Vec­tor to syn­chro­ni­zo­wa­na od­mia­na Array­List. Jeżeli two­rzo­ny kod jest jed­no­wąt­ko­wy (lub syn­chro­ni­za­cja jest rea­li­zo­wa­na na wyż­szym po­zio­mie), ich uży­wa­nie za­uwa­żal­nie spo­wol­ni dzia­ła­nie pro­gra­mu.

4. Nie uży­waj sca­la­nia zmien­nych łań­cu­cho­wych

Obiekty kla­sy String są w ję­zy­ku Java nie­mu­to­wal­ne. Scalenie dwóch na­pi­sów po­wo­du­je po­wsta­nie trze­cie­go obiek­tu, któ­re­go za­war­toś­cią jest su­ma tych na­pi­sów. Z po­zo­ru nie­win­na ope­rac­ja wy­wo­ły­wa­na ku­szą­co pros­tym ope­ra­to­rem + jest we­wnętrz­nie rea­li­zo­wa­na za po­mo­cą obiek­tu ty­pu String­Bu­il­der — i to w nie­op­ty­mal­ny spo­sób.

W każ­dym przy­pad­ku na­le­ży za­tem za­stę­po­wać sca­le­nie trzech lub wię­cej na­pi­sów włas­nym ko­dem two­rzą­cym obiekt String­Bu­il­der o od­po­wied­niej su­ma­rycz­nej po­jem­noś­ci i za­peł­nia­ją­cym go treś­cią. W ten spo­sób sca­le­nie zo­sta­nie zre­ali­zo­wa­ne bez kosz­tow­nych re­alo­ka­cji pa­mię­ci.

Taki spo­sób sca­la­nia war­to sto­so­wać na­wet w rzad­ko wy­ko­ny­wa­nym ko­dzie, na przy­kład zgła­sza­ją­cym błę­dy. Nieopty­mal­nie wy­ko­ny­wa­na ope­rac­ja – na­wet rea­li­zo­wa­na tyl­ko raz na pa­rę mi­nut – mo­że sku­tecz­nie zwięk­szyć za­śmie­ce­nie ster­ty. Jest to szcze­gól­nie istot­ne w śro­do­wis­ku wie­lo­do­stęp­nym, gdy ocze­ku­je­my, że błę­dy zgła­sza­ne jed­ne­mu użyt­kow­ni­ko­wi nie bę­dą zmniej­sza­ły wy­daj­noś­ci reali­zo­wa­nia tran­sak­cji in­nych użyt­kow­ni­ków.

Co da­lej?

Każdy za­in­te­re­so­wa­ny two­rze­niem efek­tyw­nych apli­kac­ji Java po­wi­nien prze­de wszyst­kim prze­czy­tać książ­kę „Effective Java” (autor: Joshua Bloch). Warto też przej­rzeć (i ob­ser­wo­wać na bie­żą­co) blog Java Perfor­man­ce Tu­ning Gui­de.

Przede wszyst­kim jed­nak nie na­le­ży bać się za­glą­dać do tek­stu źród­ło­we­go bi­blio­tek ję­zy­ka Java (są do­stęp­ne dla każ­de­go) i czy­tać do­ku­men­ta­cji klas bi­blio­tecz­nych (jest bar­dzo ob­szer­na i szcze­gó­ło­wa). Pod rę­ką na­le­ży mieć też na­rzę­dzia ta­kie jak VisualVM oraz Java Mission Control (włą­czo­ne stan­dar­do­wo do dys­try­buc­ji Java 7 i Java 8).