CodeGym /Kursy /JAVA 25 SELF /Analiza błędów podczas asynchronicznej pracy z plikami

Analiza błędów podczas asynchronicznej pracy z plikami

JAVA 25 SELF
Poziom 56 , Lekcja 4
Dostępny

1. Błędy z buforami: ByteBuffer, position i limit

Asynchroniczne metody odczytu i zapisu pracują z obiektem ByteBuffer. W odróżnieniu od zwykłych tablic bufor ma „wewnętrzny kursor” – pozycję (position) i limit (limit), które określają, jakie bajty będą odczytywane lub zapisywane. Jeśli niewłaściwie zarządzasz tymi właściwościami, możesz otrzymać inny wynik, niż oczekujesz, albo w ogóle zepsuć logikę.

Jak to wygląda w kodzie?

Przykład błędnego użycia:

ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // Ojej! Próba natychmiastowego odczytu łańcucha z bufora:
        String str = new String(buf.array()); // To jest nieprawidłowe!
        // ...
    }
    // ...
});

Co jest nie tak?

  • Po odczycie bufor znajduje się w trybie „zapisu”: position wskazuje koniec odczytanych danych, a limit — rozmiar bufora. Jeśli od razu zaczniesz czytać z bufora, dostaniesz mnóstwo śmieci (wszystkie 1024 bajty, nawet jeśli faktycznie odczytano 10).
  • Wywołanie buf.array() zwraca całą wewnętrzną tablicę, a nie tylko odczytaną część. Ponadto dla buforów bezpośrednich (alokowanych za pomocą ByteBuffer.allocateDirect) array() zgłosi UnsupportedOperationException.

Jak należy to robić?

Przed odczytem danych z bufora należy wywołać buffer.flip() – przełączy to bufor w tryb „odczytu”:

public void completed(Integer result, ByteBuffer buf) {
    buf.flip(); // Teraz position = 0, limit = liczba odczytanych bajtów
    String str = StandardCharsets.UTF_8.decode(buf).toString();
    // ... przetwarzanie łańcucha
}

Ponowne użycie bufora

Jeśli chcesz ponownie użyć bufora do następnej operacji, nie zapomnij wywołać buffer.clear() lub buffer.compact() po przetworzeniu danych:

  • clear() — całkowicie „zeruje” granice: position=0, limit=capacity(), stare dane uznaje się za śmieci.
  • compact() — zachowuje nieprzeczytane bajty na początku bufora i przygotowuje go do dopisywania.

Niuans: jeśli result jest równe -1, osiągnięto koniec pliku – dodatkowego przetwarzania nie będzie.

2. Dostęp równoległy: wyścigi i niespójność

AsynchronousFileChannel umożliwia uruchamianie kilku operacji równolegle. Jeśli jednak nie kontrolujesz tego procesu, łatwo otrzymać „uszkodzone” dane albo doprowadzić do awarii programu.

Problem 1: jednoczesny odczyt do jednego bufora

// Dwa jednoczesne odczyty do tego samego bufora
channel.read(buffer, 0, buffer, handler1);
channel.read(buffer, 1024, buffer, handler2);

Oba odczyty zapisują do tego samego bufora! Jeśli oba zakończą się niemal jednocześnie, zawartość bufora będzie nieprzewidywalna.

Problem 2: jednoczesny zapis do jednego pliku

Jeśli dwa wątki jednocześnie zapisują w ten sam obszar pliku, wynik zależy od tego, która operacja zakończy się wcześniej. To klasyczny wyścig (race condition), który może prowadzić do uszkodzenia danych.

Jak tego uniknąć?

  • Dla każdej operacji asynchronicznej używaj osobnego bufora (ByteBuffer) — wątki nie będą „wchodzić” sobie w pamięć.
  • Nie uruchamiaj równoległych zapisów na ten sam zakres pliku; rozdzielaj offsety lub synchronizuj dostęp.
  • Jeśli ważna jest ścisła kolejność, uruchamiaj nową operację dopiero po zakończeniu poprzedniej — na przykład z completed(...) w CompletionHandler.

3. Wycieki zasobów: zapomniano zamknąć kanał

Kanał asynchroniczny to zasób systemowy. Jeśli go nie zamkniesz (channel.close()), plik pozostanie „zajęty” w systemie, mogą wystąpić wycieki pamięci, a w systemie Windows — dodatkowo blokada pliku dla innych programów.

Typowy błąd:

AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, ...);
// ... uruchomiono operację
// zapomniano wywołać channel.close() po zakończeniu wszystkich operacji!

Jak zrobić to poprawnie?

Używaj try-with-resources i koniecznie poczekaj na zakończenie wszystkich operacji przed wyjściem z bloku:

CountDownLatch latch = new CountDownLatch(1);

try (AsynchronousFileChannel channel =
         AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {

    ByteBuffer buf = ByteBuffer.allocate(4096);
    channel.read(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
        @Override public void completed(Integer r, ByteBuffer b) {
            // przetwarzanie...
            latch.countDown();
        }
        @Override public void failed(Throwable ex, ByteBuffer b) {
            ex.printStackTrace();
            latch.countDown();
        }
    });

    latch.await(); // Czekamy na zakończenie operacji asynchronicznej
}
// Zamknięcie nastąpi automatycznie

4. Obsługa wyjątków: ignorowanie błędów w CompletionHandler

W kodzie asynchronicznym błędy nie „wystrzeliwują” w główny wątek — trafiają do metody failed(...) interfejsu CompletionHandler. Jeśli jej nie zaimplementujesz lub pozostawisz pustą, błędy po prostu znikną, a program zacznie zachowywać się dziwnie.

Przykład „niewidocznego” błędu:

channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // ... przetwarzanie wyniku
    }
    @Override
    public void failed(Throwable exc, ByteBuffer buf) {
        // Ojej, pusto! Błąd zniknął
    }
});

Jak zrobić to poprawnie?

@Override
public void failed(Throwable exc, ByteBuffer buf) {
    System.err.println("Błąd podczas odczytu pliku: " + exc.getMessage());
    exc.printStackTrace();
    // Możliwe: zamknąć kanał, zaktualizować metryki, powiadomić użytkownika itd.
}

5. Utrata referencji do Future/CompletionHandler

Jeśli uruchomisz operację asynchroniczną przez Future, ale zapomnisz zachować referencję, nie będziesz mógł anulować operacji ani poczekać na jej zakończenie. Podobnie, jeśli używasz CompletionHandler, ale nie zsynchronizujesz zakończenia wszystkich operacji, program może zakończyć się przedwcześnie.

Przykład:

channel.read(buffer, 0, buffer, handler); // handler — anonimowy, nigdzie nieprzechowywany
// Program natychmiast się zakończył, nie czekając na koniec odczytu

Jak zrobić to poprawnie?

  • Pracując z Future<Integer>: zachowuj referencję i używaj future.get() albo future.cancel(true) w razie potrzeby.
  • Przy użyciu CompletionHandler: stosuj mechanizmy synchronizacji (CountDownLatch, Semaphore) i poprawnie czekaj na zakończenie wszystkich operacji przed zamknięciem kanału/programu.

6. Błędy związane z kodowaniami

Odczyt i zapis plików tekstowych wymagają poprawnego obchodzenia się z kodowaniami. Jeśli „wprost” czytasz bajty jako łańcuchy, możesz otrzymać krzaki albo utracić część danych, zwłaszcza przy odczycie pliku porcjami.

Problem:

// Czytamy plik po 1024 bajty, a potem zamieniamy na łańcuch
String chunk = new String(buffer.array(), "UTF-8");

Jeśli znak zostanie rozbity między dwa bufory (na przykład jeden bajt symbolu UTF-8 zostanie na końcu jednego bufora, a pozostałe — na początku następnego), otrzymasz niepoprawne znaki lub błąd dekodowania.

Jak zrobić to poprawnie? Użyj CharsetDecoder i przechowuj „resztki” niedokończonych znaków między odczytami:

CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
        .onMalformedInput(CodingErrorAction.REPORT)
        .onUnmappableCharacter(CodingErrorAction.REPORT);

ByteBuffer byteBuf = ByteBuffer.allocate(4096);
CharBuffer charBuf = CharBuffer.allocate(4096);

// Przy każdym completed(...):
byteBuf.flip();
CoderResult cr = decoder.decode(byteBuf, charBuf, false); // false — to nie koniec danych wejściowych
if (cr.isError()) {
    cr.throwException();
}
byteBuf.compact();
charBuf.flip();
String text = charBuf.toString();
charBuf.clear();

// Gdy danych wejściowych już nie będzie:
decoder.flush(charBuf);

7. Przedwczesne zakończenie programu

Operacje asynchroniczne wykonują się w innych wątkach. Jeśli wątek główny zakończy się wcześniej niż wszystkie operacje, program zakończy działanie, nie czekając na wynik.

Przykład:

// Uruchamiamy asynchroniczny odczyt
channel.read(buffer, 0, buffer, handler);
// Wątek główny natychmiast się zakończył — program się zamknął, operacja nie zdążyła się zakończyć

Jak zrobić to poprawnie?

Użyj CountDownLatch, Semaphore lub chociaż Thread.sleep(...) (dla demo), aby poczekać na zakończenie wszystkich operacji:

CountDownLatch latch = new CountDownLatch(1);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override public void completed(Integer result, ByteBuffer buf) { /* ... */ latch.countDown(); }
    @Override public void failed(Throwable exc, ByteBuffer buf) { /* ... */ latch.countDown(); }
});
latch.await(); // Czekamy na zakończenie operacji

8. Nieudana integracja z ExecutorService

AsynchronousFileChannel pozwala wskazać własny ExecutorService do obsługi zdarzeń. Jeśli przekażesz pulę z małą liczbą wątków lub tylko jednym wątkiem, wszystkie operacje będą wykonywane sekwencyjnie, a nie równolegle. Jeśli pula będzie zbyt duża — otrzymasz zbędne narzuty na przełączanie kontekstu.

Przykład:

ExecutorService executor = Executors.newSingleThreadExecutor();
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, options, executor);
// Wszystkie operacje asynchroniczne będą w istocie synchroniczne!

Jak zrobić to poprawnie?

  • Dobieraj rozmiar puli do realnego obciążenia i liczby równoczesnych operacji.
  • Dla większości zadań nadają się ForkJoinPool.commonPool() albo Executors.newCachedThreadPool().
  • Pamiętaj, że przekazany ExecutorService zarządza wywołaniami callbacków (completed/failed), a nie samym dyskowym I/O.
1
Ankieta/quiz
Asynchroniczne operacje na plikach, poziom 56, lekcja 4
Niedostępny
Asynchroniczne operacje na plikach
Asynchroniczne operacje na plikach
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION