Browse Author: Bartosz Rybak

Notatka: Sortowanie bąbelkowe

Zacząłem sobie przypominać czy też uczyć się algorytmów, ostatnio przerobiłem drzewo binarne jako strukturę danych, dzisiaj zabiorę się za Sortowanie Bąbelkowe.

Na czym polega ten algorytm?

Jak sama nazwa wskazuje jest to algorytm sortujący dane, nie jest on najszybszy ale o tym za chwilę. Algorytm ten jest stosunkowo prosty w implementacji. Swoją nazwę wziął od tego, że największa liczba w sortowanej kolekcji jest podobna do pęcherzyka powietrza (bąbelka) wypływającego pod wpływam ciśnienia z wody. Tutaj tak samo tutaj “wypychamy” największą liczbę na ostatnie miejsce (też zależy w którą stronę sortujemy). Efekt ten uzyskuję się poprzez zamianę dwóch sąsiadujących elementów kolekcji, w taki sposób, że gdy liczba na pozycji N jest mniejsza niż następna czyli N+1 to trzeba je zamienić miejscami.

Przykład:

Dłuższy przykład można zobaczyć tutaj: link. Pan programista (jak mniemam) świetnie pokazuje przykład sortowania bąbelkowego krok po kroku, polecam. Ja pokaże to tylko na 4 liczbach:

[4, 1, 3, 2]

Aby posortować taką tablicę należy wykonać następujące czynności:

  • porównać 4 i 1, 4 jest większe więc zamieniamy z 1 i jest: [1, 4, 3, 2]
  • porównać 4 i 3, 4 jest większe więc zamieniamy z 3 i jest: [1, 3, 4, 2]
  • porównać 4 i 2, 4 jest większe wiec zamieniamy z 2 i jest: [1, 3, 2, 4]

W ten sposób “przepchnęliśmy” największą liczbę czyli 4 na koniec kolekcji. Oczywiście to nie koniec bo taką operację trzeba powtórzyć tak aby całość była przesortowana. Złożoność obliczeniowa w najgorszym przypadku to O(n2) więc może to trochę potrwać. Zatem dalej porównując:

  • porównać 1 i 3, 1 jest mniejsze więc zostaje na swojej pozycji
  • porównać 3, 2, 3 jest większe więc zamieniamy z 2 i jest: [1, 2, 3, 4]
  • porównać 3 i 4, 3 jest mniejsze więc zostaje na swojej pozycji

Mogło by się wydawać, że to już koniec ale w niezoptymalizowanej wersji algorytmu wykona się to wszystko jeszcze raz pomimo, że kolekcja jest już posortowana.

Złożoność obliczeniowa i optymalizacja

W najgorszym przypadku jest to właśnie O(n2), jeśli użyjemy optymalizacji można w najlepszym przypadku zejść do O(n) a w typie optymalizacji użytym na filmie O(n) i złożoności pamięci O(1). Tak jak pokazał to Pan na filmie, można to zoptymalizować na 2 sposoby. Ja pisząc swój algorytm na początku z głowy, szukając czy dałoby się to napisać funkcyjnie, natknąłem się na inne możliwości zapisu algorytmu a także kolejną formę optymalizacji.

  1. Optymalizacja 1 – brak sprawdzania całości jeszcze raz po przesortowaniu, trzeba dołożyć dodatkową flagę i jeśli nie było “zamian” w poprzednim przejściu to zakończyć pętlę sprawdzania bo mamy już tablicę przesortowaną na 100%
  2. Optymalizacja 2 – brak sprawdzania liczb które są najbardziej od prawej, w skrócie jeśli mamy 2,1, 3, 6, 7, 8 to po co sprawdzać za każdym następnym przejściem 6, 7, 8? Przecież one już się nie zmienią. Czyli jeśli nie było zamiany z ostatnią pozycją to zmniejszamy długość sprawdzania o 1 i tak za każdym razem.
  3. Optymalizacja 3 – znaleziona tu, jest to jakby połączenie dwóch poprzednich optymalizacji, algorytm zatrzymuje się jeśli w poprzednim przebiegu nie było żadnej zmiany oraz nie uwzględnia ostatnich pozycji

Kod:

Aby pokazać różnice czasowe i ilości porównań stworzyłem sobie klasę implementującą 3 interfejsy, Sort, OptimizedSort, SuperOptimizedSort (tak wiem nazwy mogłyby być lepsze :)). Są 3 i są generyczne, ponieważ mogą mi się jeszcze przydać przy następnych algorytmach i dla innych typów, poza tym zachowana zostaje zasada Segregacji Interfejsów (I w mnemoniku SOLID). Następnie zaimplementowałem w klasie BubbleSort wszystkie 3 metody (po jednej z każdego interfejsu). Następnie wrzuciłem to w wątki bo przy większych liczbach może to być czasowo problematyczne. Dodałem też drukowanie ilości porównań wewnątrz algorytmu.

Interfejsy:

public interface SortAlgorithm> {
    T[] sort(T[] arrayToSort);
}
public interface OptimizedSort> {
    T[] optimizedSort(T[] arrayToSort);
}
public interface SuperOptimizedSort {
    T[] mostOptimizedSort(T[] arrayToSort);
}

Klasa BubbleSort:

package sorting;

import static java.lang.String.format;

public class BubbleSort> implements SortAlgorithm, OptimizedSort, SuperOptimizedSort {

    //iteration way, non optimized
    @Override
    public T[] sort(T[] arrayToSort) {
        int count = 0;
        for (int i = 0; i < arrayToSort.length - 1; i++) {
            for (int j = 0; j < arrayToSort.length - 1; j++) {
                count++;
                if (arrayToSort[j].compareTo(arrayToSort[j + 1]) > 0) {
                    swapElements(arrayToSort, j, j + 1);
                }
            }
        }
        printNumberOfComparisions(count, "(non optimized)");
        return arrayToSort;
    }

    //optimized sort with additional flag
    @Override
    public T[] optimizedSort(T[] arrayToSort) {
        int count = 0;
        boolean swapped = true;
        for (int i = 1; i < arrayToSort.length && swapped; i++) {
            swapped = false;
            for (int j = 0; j < arrayToSort.length - i; j++) {
                count++;
                if (arrayToSort[j].compareTo(arrayToSort[j + 1]) > 0) {
                    swapElements(arrayToSort, j, j + 1);
                    swapped = true;
                }
            }
        }
        printNumberOfComparisions(count, "(optimized)");
        return arrayToSort;
    }

     /* optimized way 1 + 2 from here: http://www.algorytm.org/algorytmy-sortowania/sortowanie-babelkowe-bubblesort/bubble-3-j.html */
    @Override
    public T[] mostOptimizedSort(T[] arrayToSort) {
        int count = 0;
        int n = arrayToSort.length - 1;
        while (n > 0) {
            int last = 0;
            for (int j = 0; j < n; j++) {
                count++;
                if (arrayToSort[j].compareTo(arrayToSort[j + 1]) > 0) {
                    swapElements(arrayToSort, j, j + 1);
                    last = j;
                }
            }
            n = last;
        }
        printNumberOfComparisions(count, "(most optimized)");
        return arrayToSort;
    }

    private void swapElements(T[] arrayToSort, int i, int j) {
        T temp = arrayToSort[i];
        arrayToSort[i] = arrayToSort[j];
        arrayToSort[j] = temp;
    }

    private void printNumberOfComparisions(int count, String typeOfOptimization) {
        System.out.printf("Number of comparisions %s: %d%n", typeOfOptimization, count);
    }
}

Klasa Main gdzie tworze sobie wątki:

import java.util.Arrays;
import java.util.Random;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Random random = new Random();
        Integer[] array = new Integer[1000];
        
        final int length = array.length;
        array = Arrays.stream(array).map(x -> random.nextInt(length)).toArray(Integer[]::new);

        final Integer[] copyOfArray = Arrays.copyOf(array, array.length);

        Thread t = getThreadWithTimeCounter(() -> printAfterSort(copyOfArray), "non-sorted");
        t.start();

        Thread t1 = getThreadWithTimeCounter(() -> printAfterOptimizedSort(copyOfArray), "optimized");
        t1.start();

        Thread t2 = getThreadWithTimeCounter(() -> printAfterMostOptimizedSort(copyOfArray), "most-optimized");
        t2.start();
    }

    private static Thread getThreadWithTimeCounter(Runnable runnable, String name) {
        Thread t = new Thread(() -> {
            long startTime = System.currentTimeMillis();
            runnable.run();
            long endTime = System.currentTimeMillis();
            System.out.printf("Time in millis for runnable %s %d%n", name,  endTime - startTime);
        });
        return t;
    }

    private static void printAfterSort(Integer[] array) {
        Integer[] copy = Arrays.copyOf(array, array.length);
        BubbleSort bubbleSort = new BubbleSort<>();
        bubbleSort.sort(copy);
//        System.out.println(Arrays.toString(bubbleSort.sort(copy)));
    }

    private static void printAfterOptimizedSort(Integer[] array) {
        Integer[] copy = Arrays.copyOf(array, array.length);
        OptimizedSort optimizedSort = new BubbleSort<>();
        optimizedSort.optimizedSort(copy);
//        System.out.println(Arrays.toString(optimizedSort.optimizedSort(copy)));
    }

    private static void printAfterMostOptimizedSort(Integer[]  array) {
        Integer[] copy = Arrays.copyOf(array, array.length);
        SuperOptimizedSort bubbleSort = new BubbleSort<>();
        bubbleSort.mostOptimizedSort(copy);
//    
 System.out.println(Arrays.toString(bubbleSort.mostOptimizedSort(copy)));
    }
}

Wnioski:

O ile w mniejszych zbiorach danych nie widać szczególnej różnicy między Optymized i SuperOptimized to w przypadku większych zbiorów różnice są zauważalne.

Dla 100_000 elementów wyniki przedstawiają się u mnie następująco:

Number of comparisions (most optimized): 704632143
Time in millis for runnable most-optimized: 50785

Number of comparisions (optimized): 704948251
Time in millis for runnable optimized: 51876

Number of comparisions (non optimized): 1409865409
Time in millis for runnable non-sorted: 96376

Widać więc ile porównań było w najmniej optymalnej wersji, jest to cały rząd wielkości więcej porównań. Algorytmy optymalne różnią się o 316_108 porównań, to dość sporo, choć czasowo to tylko około sekunda różnicy.

Algorytm bąbelkowy nie może wydajny ale ma fajną nazwę, do tego jest dość prosty w implementacji, więc można go łatwo przyswoić.

Linki z których korzystałem:

http://www.algorytm.org/algorytmy-sortowania/sortowanie-babelkowe-bubblesort/bubble-3-j.html

https://stackoverflow.com/questions/27639792/java-implementation-of-bubble-sort-in-functional-style

Notatka: Binary Tree

Ta notatka dotyczy dość znanej i popularnej strukturze jaką jest drzewo binarne.

Co to jest drzewo binarne?

Drzewa posiadają korzeń, gałęzie oraz liście, w strukturze drzewa jest podobnie, z tym, że jest różne nazewnictwo poszczególnych elementów takiej struktury. Drzewo binarne wyróżnia się tym, że liczba gałęzi od każdego wierzchołka nie jest większa niż 2. Powiedzmy, że pierwszym wierzchołkiem jest korzeń, nie może mieć on więcej niż 2 dzieci, następnie schodzimy niżej i każde z jego dzieci też nie może mieć więcej niż 2 dzieci i tak dalej…

https://www.pngitem.com/pimgs/m/491-4915022_binary-search-tree-hd-png-download.png

Jak widać na powyższym drzewie, wierzchołek może mieć maksymalnie 2 gałęzie, ale może mieć też 1.

Szukanie w drzewie binarnym

Aby wyszukiwać w drzewie binarnym powinno być ono prawidłowo poukładane/posortowane, gałąź po lewej stronie zawsze powinna mieć niższą wartość niż gałąź po prawej stronie. Jest to wtedy tak zwane Binarne Drzewo Poszukiwań (BST – Binary Search Tree). Sprawia to, że bardzo łatwo szukać elementu w takim drzewie. Powiedzmy, że w strukturze której zdjęcie jest wyżej, chcemy wyszukać liczbę 4. Idąc od góry sprawdzamy czy liczba 4 jest większa czy mniejsza niż root (8), jest mniejsza więc idziemy w lewą stronę, teraz czy liczba 4 jest większa czy mniejsza niż liczba 3? Jest większa więc idziemy w prawo, jesteśmy teraz przy liczbie 6 która jest większa niż 4 więc idziemy w lewo. I voila! Po 3 sprawdzeniach znaleźliśmy liczbę.

W Javie to będzie np. taki kod, samo sprawdzanie czy element istnieje w drzewie:

/* 
ten kod pochodzi z tego filmu: https://www.youtube.com/watch?v=oSWTXtMglKE 
aczkolwiek przepisałem go do IDE i w pełni go rozumiem 
*/
public class Node {

    private Node left, right;
    private final int data;

    public Node(int data) {
        this.data = data;
    }

    public boolean contains(int value) {
        if (value == data) {
            return true;
        } else if (value < data) {
            if (left == null) {
                return false;
            } else {
                return left.contains(value);
            }
        } else {
            if (right == null) {
                return false;
            } else {
                return right.contains(value);
            }
        }
    }
}

Dodawanie elementu do drzewa

Dodawanie odbywa się w taki sam sposób jak wyszukiwanie wartości z tym, że szukamy dogodnego miejsca do umieszczenia nowego elementu. Sprawdzamy czy wartość nowego elementu, jest mniejsza czy większa niż porównywany wierzchołek. Jeśli mniejsza to idziemy w lewo, jeśli większa to w prawo, i tak aż znajdziemy miejsce...

W Javie można to napisać z użyciem rekurencji:

/* 
ten kod pochodzi z tego filmu: https://www.youtube.com/watch?v=oSWTXtMglKE 
aczkolwiek przepisałem go do IDE i w pełni go rozumiem 
*/
public void insert(int value) {
    if (value <= data) {
        if (left == null) {
            left = new Node(value);
        } else {
            left.insert(value);
        }
    } else {
        if (right == null) {
            right = new Node(value);
        } else {
            right.insert(value);
        }
    }
}

Jaki jest problem z wyszukiwaniem i dodawaniem nowego elementu?

Jeśli dodajemy posortowane elementy, to po prostu robi nam się zwykła lista (taka jedna długa gałąź), co znacznie obniża efektywność takiego rozwiązania, w najgorszym przypadku wpadamy w złożoność obliczeniową O(n) czy to przy wyszukiwaniu z takiego "drzewa" czy przy dodawaniu. Zoptymalizowane drzewa mają tą złożoność na poziomie O(log n). Takie drzewo to np. drzewo czerwono-czarne.

Przechodzenie po drzewie - traversing

Są 3 sposoby przechodzenia po drzewie binarnym. Uporządkowane (inorder), ?przed uporządkowaniem? (preorder) i ?po uporządkowaniu? (postorder). Nie wiem jak to do końca dobrze przetłumaczyć więc tak to zostawię.

  • Preorder - root, później lewa, później prawa
  • Inorder - lewa, root, prawa
  • Postorder - lewa, prawa, root

Najlepiej jest przechodzić w sposób inorder czyli uporządkowany wtedy idąc od lewej do prawej, wartości wierzchołków są w kolejności rosnącej.

Drukowanie inorder, pozostałem przechodzenia wyglądają tak samo tylko trzeba pozamieniać linie.

/* 
ten kod pochodzi z tego filmu: https://www.youtube.com/watch?v=oSWTXtMglKE 
aczkolwiek przepisałem go do IDE i w pełni go rozumiem 
*/
public void printInOrder() {
        if (left != null) {
            left.printInOrder();   // (1)
        }
        System.out.println(data); //(2)
        if (right != null) {
            right.printInOrder(); //(3)
        }
}

Jeśli chcemy wydrukować w kolejnośći preorder to będzie idąc od góry (2), (1), (3), a jeśli w kolejności postorder to: (1), (3), (2).

Zadanie z drzewem binarnym

Dana funkcja isBinaryTree(strArr) jako argument przyjmuje tablicę Stringów, tablica ta zawiera pary liczb w następującym formacie: (i1, i2), gdzie i1 reprezentuje gałąź dziecko, a i2 reprezentuje gałąź rodzica dla i1. Np. ["(1, 2)", "(2, 4)", "(7, 2)"], jest to wtedy poprawne drzewo binarne, ponieważ każda gałąź ma nie więcej niż dwoje dzieci. Program ma zwracać boolean true jeśli dane drzewo jest poprawnym drzewem binarnym, i false jeśli nie jest. Wszystkie dane gałęzie będą unikalne to znaczy będzie tylko jeden node z daną wartością np. 2.

Moje rozwiązanie:

public static boolean isBinaryTree(String[] nodeValuesArray) {
    //najpierw wyjąłbym sobie wartości liczbowe z typu String
    Integer[] numbers = convertToIntegerArray(nodeValuesArray);
    /* żaden node nie może być rodzicem więcej niż 2 razy,
        można wywnioskować że wystarczy po prostu sprawidzić czy liczba na drugiej pozycji występuje więcej
        niż 2 razy, jeśli nie, to będzie to poprawne drzewo binarne */
    Map collect = IntStream.range(0, numbers.length)
            .filter(i -> i % 2 == 0)
            .map(i -> numbers[i+1])
            .boxed()
            .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
    return collect.values().stream().noneMatch(occurrence -> occurrence > 2);
}

private static Integer[] convertToIntegerArray(String[] nodeValuesArray) {
    return Arrays.stream(nodeValuesArray)
            .map(string -> string.replaceAll("\\)|\\(", ""))
            .map(string -> string.split(","))
            .flatMap(Stream::of)
            .map(Integer::parseInt)
            .toArray(Integer[]::new);
}

public static void main(String[] args) {
        String[] firstTree = {"(1,2)", "(2,4)", "(5,7)", "(7,2)", "(9,5)"};
        String[] secondTree = {"(1,2)", "(3,2)", "(2,12)", "(5,2)"};
        String[] thirdTree = {"(3,8)", "(10,8)", "(1,3)", "(6,3)", "(4,6)", "(7,6)", "(14,10)", "(13,14)"};

        System.out.println(isBinaryTree(firstTree)); //output true
        System.out.println(isBinaryTree(secondTree)); //output false
        System.out.println(isBinaryTree(thirdTree)); //output true
    }

Trochę "shackowałem" system bo nie użyłem nigdzie algorytmu drzewa, sprawdziłem po prostu czy "rodzic" występuje w tablicy stringów więcej niż 2 razy, jeśli tak to drzewo nie jest poprawne bo może mieć maksymalnie dwoje dzieci. Jak widać trzy podane "testy" przeszły, jeśli jednak jest jakieś drzewo, które jest poprawne a mój algorytm zwraca zły wynik, skontaktuj się ze mną abym mógł poprawić rozwiązanie.

Linki:

https://pl.wikipedia.org/wiki/Drzewo_binarne

https://pl.wikipedia.org/wiki/Binarne_drzewo_poszukiwa%C5%84

Streszczenie: Parallelizm i programowanie asynchroniczne ze Stream API oraz CompletableFuture cz. II

Poniższy wpis jest streszczeniem drugiej części wykładu Venkata Subramaniama z konferencji Devoxx 2018 na teamat parallelizmu i asynchronicznośći w Javie. Wykład można znaleźć pod tym linkiem: https://www.youtube.com/watch?v=0hQvWIdwnw4.

Completable Futures

  • wykonania asynchroniczne
  • nieblokujące
  • nie czeka się na wywołanie metody ani nawet na jej start

Jak to było z Future?

Zbieranie wyników wyglądało mniej więcej tak:

Future future = call();
...performing another operations
future.get(); - you get stuck

Jak określił to Venkat – “It is not future at all”.

Co ze streamami?

Jak wygląda obsługa błędów w java Stream API? Wygląda na zasadzie: “good luck”. Programowanie funkcyjne w przypadku streamów nie za bardzo lubi się z obsługą wyjątków i dość ciężko to zrobić.

Lekcja z JavaScript

JavaScript posiadała coś takiego jak Callbacks jednak:

  • występował brak spójności
  • ciężko się to komponowało z innymi callbackami (callback hell)
  • nie było spójności w radzeniu sobie z błędami

Potem przyszły Promises

Promises posiadają 3 stany: may resolve, reject (error), pending (decide), pierwsze 2 są niezmienne, pending może zmienić się w inny stan.

Promises (które przesyłają 0 lub 1 piece of data) posiadają 2 kanały komunikacji:

  • data ——–>
  • error ——-> (to jest inna forma danych, ale nadal danych, “errors are first class citiziens“)

Na końcu jest rezultat w postaci danych lub błędu.

Completable Futures to takie javascriptowe Promises na sterydach. Można je nazwać “Promises of Java world“. To jest ta sama idea, ten sam pomysł. Są bardzo podobne w działaniu.

Completable Futures też mają stages (stage = pipeline of execution), można przechodzić ze stanu do stanu itd. tylko, że każdy stage bierze CompletableFuture i zwraca CompletableFuture. Można więc je porównać (jak Venkat to zrobił) do złego charakteru w filmach który zawsze powraca, lub do młodu thora, lub do ciągle jadącego nieskończonego pociągu – nie ma sposobu na “zabicie” takiego CompletableFuture bo zawsze tworzy nowy i nowy. Takie wywołanie jest nieskończone, po prostu należy wsiąść do pociągu, zabrać swoje dane i się ulotnić.

Edit: doczytałem w innym źródle, że można zakończyć CompletableFuture albo poprzez zakończenie puli wątków albo wywołanie cancel() co jednak rzuci CancellationException dla siebie i dla zależnych transformacji.

Jak tworzyć CompletableFuture?

Należy zaimportować java.util.concurrent.*, ponieważ z takiego pakietu pochodzą. Następnie:

public static CompletableFuture create() {
    return CompletableFuture.supplyAsync(() -> 2); //to akurat zwraca tylko 2
}
//i później np.
public static void main(String[] args) {
    CompletableFuture future = create();
    //oraz proste zbieranie daty do consumera
    future.thenAccept(data -> System.out.println(data));
}

No dobrze ale powyżej tylko wydrukowaliśmy dane, nie zabraliśmy ich. Co jeśli chcielibyśmy dać kolejne .thenAccept()? Nie da się bo poprzednie wywołanie nie zwraca nam nic do zaakceptowania, zwraca void którego nie da się procesować. Można sobie tutaj co najwyżej użyć .thenRun(() -> System.out.println(“Powyżej wydrukowaliśmy dane”); i wydrukować informację. Można to nadal kontynuować i dołożyć kolejne .thenRun(), które nic nie przyjmują ani nic nie zwracają ale mogą coś wykonać (to jest odpowiednik zachowania z interfejsu funkcyjnego Runnable).

thenAccept? thenRun? Co to takiego?

CompletableFuture używa tego samego api co streamy tylko twórcy nadali tym metodom inne nazwy (dlaczego? – ja nie wiem). Należałoby zatem przypomnieć sobie kilka popularnych interfejsów funkcyjnych oraz do czego na przykład są używane w streamach:

  • Supplier<T> – T get(); – factories
  • Predicate<T> – boolean test(T); – filter
  • Function<T, R> – R apply(T); – map
  • Consumer<T> void accept(T); – forEach używa consumera

Zatem w interfejsach funkcyjnych mamy apply() -> tutaj mamy thenApply(), tam mamy accept(), tutaj thenAccept() itd.

Jak więc pobrać dane?

Musimy z nimi po prostu coś zrobić, albo przekazać dalej do procesowania, albo wydrukować, albo zmapować, da się jeszcze użyć takich metod jak .get() i .getNow(0). ale nie jest to zbytnio zalecane bo:

  • .get() – to jest blokujący call i jak ma jakiś wait w środku to będzie czekało na rezultat
  • .getNow(0) – jest co prawda nieblokujący ale też niecierpliwy, po prostu zamiast czekać na rezultat zwraca argument przekazany, w tym przypdaku 0

.thenAccept() – jest zatem Consumerem – zwraca CompletableFuture<Void>

.thenRun() – jest Runnable – można np użyć do wyświetlenia informacji lub zareportowaniu czegoś

W którym wątku wykonują się operacje?

To że patrzymy na ten CompletableFuture to nie znaczy, że wiemy który wątek to wykonuje. Jeśli CompletableFuture jest już completed to następne operacje wykonują się w głównym wątku (znaczy tym z którego wyszliśmy), jeśli nie jest jeszcze completed to w innym. Czekanie na rezultat nie może nam blokować wątku, trzeba kontynuować pipeline. Można sobie zmienić wywołanie z common pool na swoją własną pulę.

ForkJoinPool pool = new ForkJoinPool(10);
//i później
return CompletableFuture.supplyAsync(() -> compute(), pool);

.supplyAsync() to metoda która zwraca CompletableFuture który wykona się właśnie w ForkJoinPool o ile nie podamy swojej puli wątków.

Im laazy

Podobnie jak streamy są leniwe tak też tutaj możemy odroczyć wykonanie takiego pipeline. Najpierw tworzymy pipelnie a na końcu pushujemy do niego dane.

CompletableFuture future = new CompletableFuture();
future.thenApply(data -> data * 2)
            .thenApply(data -> data + 1)
            .thenAccept(data -> System.out.println(data));
//i gdzieś dalej być może w innej metodzie
future.complete(2);
//wynikiem będzie wydrukowanie liczby 5

Porównanie Streamów i CompletableFuture

StreamCompletableFuture
zero, one or more datazero or one data
pipelinepipeline
only data channeldata and error channels
lazylazy channel
forEachthenAccept
mapthenApply (np. transformuje CF<T> -> CF<R>)
exceptions – “oops”error channel
streamy nie mają czegoś takiego jak zipcombine
flatMapthenCompose
function returns data – map
function returns Stream – flatMap
function returns data – thenAccept/thenApply
function returns CompletableFuture – thenCompose

Radzenie sobie z wyjątkami

Jeśli wszystko jest ok to używamy przeważnie metod z then jeśli coś pójdzie źle to są to przeważnie metody oznaczone exceptionally. Np:

.exceptionally() – to nic innego jak taki “catch”

Jeśli wszystko idzie dobrze to pipeline idzie do najbliższego then a jeśli coś idzie nie tak to do najbliższego exceptionally. Ale jako, że między kanałami można przeskakiwać to można także “przywrócić się do życia” po rzuceniu wyjątku, po prostu trzeba zwrócić jakąś wartość i dać następne then po tym.

Odpowiednikiem future.complete(2); jest future.completeExceptionally(new RuntimeException("Don't tell the boss");

Co jeśli czekamy w nieskończoność?

Powiedzmy, że nie mamy complete, completeExceptionally ani supplyAsync, wtedy stan jest cały czas pending (czeka na dane). Ale ile czasu będzie czekać na te dane?

Both in life and programming never do something without timeout

Venkat Subramaniam

Trzeba używać timeoutów. Nie było timeoutu w Java 8 ale od Javy 9 się pojawił. Były dwie metody w Javie 9: future.completeOnTimeout(0, 10, TimeUnit.SECONDS); 0 będzie przetwarzane jeśli CompletableFuture nie stanie się completed w ciągu 10 sekund. To jest pozytywna ścieżka. W negatywnej nie ma słówka exceptionally tym razem, zatem jest to onTimeout(2, TimeUnit.SECONDS); Przekaże 0 jako argument i rzuci wyjątek bo nie posiada danych. Jeśli wcześniej będzie gdzieś .complete(2) to te wywołania timeoutowe są pomijane.

Combine and Compose

W javie jest statyczne typowanie zatem nie możemy sobie zwrócić albo CF albo danych jak np. w javascript (jak są dane to zwraca dane, jak nie ma to promise).

Combine bierze jeden CompletableFuture i łączy z rezultatem innego CompletableFuture więc np:

private static CompletableFuture create(int number) {
    return CompletableFuture.supplyAsync(() -> number); 
}
//i potem 
create(2).thenCombine(create(3), (result1, result2) -> result1 + result2).thenAccept(data -> System.out.println(data)); 
//jak taki "join" czyli zwraca 5

Compose – w streamach mamy flatMap a tutaj thenCompose(), wygląda to mniej więcej tak:

private static CompletableFuture increment(int number) {
    return CompletableFuture.supplyAsync(() -> number + 1):
}
//i później
create(2)
    .thenCompose(data -> increment(data))
    .thenAccept(result -> System.out.println(data));
// nie można tu użyć thenApply bo increment nie zwraca danych tylko CompletableFuture

Są to podstawy głównie teoretyczne i api zmienia się z kolejnymi wyjściami wersjami Javy. Znalazłem też przydatny artykuł opisujący poszczególne metody (których jest dużo więcej: https://codecouple.pl/2018/04/06/completablefuture-dlaczego-po-co-jak/).

Zachęcam oczywiście do obejrzenia całego wykładu.

Jeżeli coś się nie zgadza lub jest jakiś błąd merytoryczny to proszę o kontakt w celu poprawy treści. Uczę się więc mogłem coś lekko pomylić lub czegoś nie do końca zrozumieć w kontekście tego co autor miał na myśli.

Refactoring: złożona metoda

W tym poście zajmę się refactoringiem jednej złożonej metody. Nie będę dogłębnie wnikał w to co metoda dokładnie robi i w jaki sposób, spojrzę na sam kod od strony refactoringu. Metodę wziąłem od kolegi piszącego na co dzień w innych językach programowania. Metoda ta ma pobierać listę zamówień od jednego z dostawców poprzez API (niestety pierwszego poziomu rest więc kolega ma z tym masę roboty).

Oto metoda przed refactoringiem:

@Override
public List getOrderList() {
    String body = String.format(XmlBuilder.orderListBody(), apiKey);

    Map headers = new HashMap<>();
    headers.put(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_XML_VALUE);

    HttpResponse response = requestBuilder.postRequest(url, headers, body);
    TransactionHeader responseHeaders = extractHeadersFromResponse(response);
    List orderList = extractOrdersFromResponse(response);

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    while (responseHeaders.getTooManyItems()) {
        LocalDateTime dateTime = LocalDateTime.parse(orderList.get(orderList.size() - 1).getCreationDateTime(), formatter);
        body = String.format(XmlBuilder.orderListBodyFromDate(dateTime.toLocalDate()), apiKey);
        response = requestBuilder.postRequest(url, headers, body);

        if(httpTooManyRequests(response)) response = requestBuilder.postRequest(url, headers, body);
        responseHeaders = extractHeadersFromResponse(response);
        List secondOrderList = extractOrdersFromResponse(response);
        for (Order x : secondOrderList) {
            if (!orderList.contains(x))
                orderList.add(x);
        }
    }
    return orderList;
}

W tej metodzie można znaleźć kilka poziomów abstrakcji funkcji ale od początku.

  1. Od Javy 10 pojawił się syntax sugar w postaci słowa var (nie jest to słowo kluczowe var var = “abc”; jest nadal poprawne). Nie jest to dynamiczne typowanie a skrócenie długich nazw typów. Użyjemy go więc kilka razy.
  2. Tworzenie mapy z headerami nie jest tutaj bardzo niezbędne więc możemy je wyekstrahować do metody prywatnej zachowując dobrą nazwę metody (żeby dobrze było wiadomo o co chodzi), np. getHeadersForOrderListRequest().
  3. DateTimeFormatter także wyrzucimy do metody prywatnej jednak pozbędziemy się części czasu (który i tak nie jest tutaj wykorzystywany), zostanie więc sama data a co za tym idzie można zmienić typ na LocalDate i pozbyć sie późniejszego wywołania .toLocalDate().
  4. Możemy użyć importów statycznych na wywołaniach metod z klasy XMLBuilder, co zwiększy czytelność
  5. Nie potrzebne jest sprawdzanie warunku w ifie skoro przed nim, wyżej, mamy to samo przypisanie wartości do zmiennej. EDIT: kolega całkowicie pozbył się później tego sprawdzania w tym miejscu na rzecz oddelegowania tego do innej klasy
  6. Programowanie imperatywne czyli np. stare (nie) dobre pętle for można zastąpić pięknym programowaniem deklaratywnym czyli w tym przypadku streamami
  7. Można też użyć .parallelStream() w celu przyspieszenia procesowania

Finalnie wersja po refactoringu wygląda tak:

@Override
public List getOrderList() {

    String body = format(orderListBody(), apiKey);
    var headers = getHeadersForOrderListRequest();
    var response = requestBuilder.postRequest(url, headers, body);
    var responseHeaders = extractHeadersFromResponse(response);
    var orderList = extractOrdersFromResponse(response);

    while (responseHeaders.getTooManyItems()) {
        body = format(orderListBodyFromDate(getLastOrderDate(orderList)), apiKey);
        response = requestBuilder.postRequest(url, headers, body);
        responseHeaders = extractHeadersFromResponse(response);

        extractOrdersFromResponse(response).stream()
                .filter(order -> !orderList.contains(order))
                .forEach(orderList::add);
    }
    return orderList;
}

private LocalDate getLastOrderDate(List orderList) {
    return LocalDate.parse(orderList.get(orderList.size() - 1).getCreationDateTime(), getDateFormatter());
}

private Map getHeadersForOrderListRequest() {
    return Map.ofEntries(Map.entry(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_XML_VALUE));
}

private DateTimeFormatter getDateFormatter() {
    return DateTimeFormatter.ofPattern("yyyy-MM-dd");
}

W metodzie mamy teraz podział na przygotowanie zmiennych i na procesowanie ich w pętli while. Zapewne można by to jeszcze jakoś dodatkowo wyczyścić ale nie chciałbym też za bardzo zaciemnić metody chowając np. deklaracje zmiennych przed pętlą while do innej metody. Zapewne dałoby się też wyekstrahować steam do osobngo bloku kodu.

Jeśli ktoś ma jeszcze jakieś propozycje usprawnienia i wyczyszczenia kodu to zapraszam do kontaktu!

Streszczenie: Parallelizm i programowanie asynchroniczne ze Stream API oraz CompletableFuture cz. I

Poniższy wpis jest streszczeniem wykładu Venkata Subramaniama z konferencji Devoxx 2018 na teamat parallelizmu i asynchronicznośći w Javie. Wykład można znaleźć pod tym linkiem: https://www.youtube.com/watch?v=0hQvWIdwnw4.

Cały wykład, czyli cześć pierwsza i druga trwają 3h14minut więc postaram się napisać co udało mi się zapamiętać i zrozumieć z tej lekcji. Venkat to świetny prelegent, który sprawia, że dość ciężkie zagadnienia “wchodzą” z łatwością do głowy.

Pierwsza część obejmuje parallelizm

Oba pojęcia czyli parallel i programowanie asynchroniczne różnią się od siebie, nie jest to to samo.

Parallelizacja – rozdzielamy jakieś zadania na mniejsze (fork), dzieląc je między dostępne wątki, na końcu łączymy (join) i zbieramy rezultat

Asynchronicznośc – operacje wykonywane są asynchronicznie, nie trzeba czekać na dostarczenie wszystkich wyników operacji z każdego wątku, główna operacja (praca) nadal trwa

Venkat porównał to do imprezy. Parallel to kiedy mówimy jednej osobie, żeby przyniosła pizzę a drugiej aby przyniosła napoje, rozdzielamy więc pracę i kiedy obie osoby przyniosą swoje składniki rozpoczynamy zabawę. Asynchroniczność to kiedy mówimy jednej osobie żeby przyniosła pizzę a drugiej aby przyniosła napoje z tym, że kiedy nie ma obu osób możemy już się bawić i tańczyć, kiedy pierwsza osoba przyniesie pizzę, jemy pizzę i dalej się bawimy, następnie druga osoba przynosi napoje, cały czas trwa impreza. Nie musimy czekać na każdy składnik aby ją zacząć.

Luźne notatki:

Streams - addiction
Collection Pipeline Pattern
//functional style - functional composition
stream() - internal iterator (tak to można nazwać)
Functional composition -> Collection Pipeline
//imperative style has accidental complexity
//functional style has less complexity and it is also easier to parallelize

Parallelizowany stream można utworzyć na dwa sposoby:

  • parallelStream() – kiedy jesteśmy kreatorem streama
  • stream().parallel() – kiedy nie jesteśmy kreatorem tylko np. dostajemy sekwencyjny stream jako wynik jakiejś funkcji, argument itp.

Venkat podkreślił, że kod sekwencyjny ma bardzo różniącą się strukturę od kodu wielowątkowego. Kiedy używamy synchronized, bardzo nadruszamy sobie strukturę kodu, do tego trzeba zapewne łapać wyjątki związane z wątkami. Kiedy używamy streamów struktura sekwencyjnego kodu jest identyczna ze strukturą kodu wielowątkowego.

  • sequential() – niweluje parallelizm w streamie

Jeśli mamy oba zarówno parallel() jak i sequential() w streamie to zastosowana jest zasada last wins, ostatnim jest ten najbliżej operacji terminalnej.

W kolejnych “slajdach” Venkat pokazał różnice miedzy Streamami a Streamami Reaktywnymi.

We solve one set of problems only to create a new set of problems

Tak o kolejnych generacjach wielowątkowości mówił Venkat Subramaniam
  • Java 1 – Threads – wiemy jak wygląda praca z wątkami na najniższym poziomie i jak wygląda kod
  • Java 5 – ExcecutorServices – duże uproszczenie, wrzucanie zadań do worka i czekanie na odpowiedź, z tym że mogły wywoływać Pool deadlock czyli sytuację, że za duże żądania cały czas dzielone na mniejsze przez wątki executora mogły wywoływać deadlock, wątki po podzieleniu zadania przekazywały zadanie dalej i czekały na kolejne zadanie do podziału, tylko, że czasami mogło zabraknąć wątków które mogłby wykonywać pracę tych podzielonych części i następował deadlock
  • Java 7 – ForkJoinPool – wątki dzielą probemy ale już nie czekają bez końca na kolejne dzielenie zadań tylko wykonują pracę innego wątku, nie marnuje się wątku tylko na czekanie, jest to bardziej efektywne wykorzystanie czasu procesora, jest to tak zwany work stealing – podbieranie pracy innego wątku kiedy nasz wątek za długo jest bezczynny

Jak wiadomo parallel nie zachowuje kolejności wywołań.

Niektóre metody są właściwie uporządkowane (inherently ordered) czyli muszą mieć zachowaną jakąś kolejność działań. Inne natomiast mogą nie być uporządkowane lecz mieć uporządkowane odpowiedniki (ordered counterpart). Aby zachować i wielowątkowość w streamie i kolejność wywołań, np. odczyt można użyć .forEachOrdered() jako operację terminalną na parallel streamie. Nadal jest to wielowątkowe wywołanie, bo zadania wykonują się równolegle jednak muszą poczekać z dokończeniem pracy na poprzednika. Metoda .forEachOrdered() gwarantuje uporządkowanie tylko jeśli przekazany miał już wcześniej możliwość bycia uporządkowanym np. tak jak Lista. Na Secie nie zadziała bo ta metoda nie wyczaruje tego uporządkowania, ona ma je tylko zachować.

  • Parallel and filter (OK)
  • Parallel and map (OK)
  • Parallel and reduce (BARDZO NIE OK), to znaczy jeśli seed czyli jak Venkat mówił NIE initial value a Identity Value (czyli wartość przekazana do operacji nie zmieniająca wyniku operacji np. w mnożeniu to liczba 1: x * 1 = x, w dodawaniu 0: x + 0 = x) to wtedy się wszystko zgadza i jest OK ale jeśli nie jest Identity to wyniki bardzo się rozjeżdżają bo każdy wątek bierze sobie tą wartość początkową i nie patrzy na to co robią inne wątki, zatem jeśli w streamie sumującym ustawimy wartość początkową na np. 30 to każdy wątek na początku sobie weźmie 30 i doda to otrzymanego wyniku

How many threads can I create? //bad question

How many threads should I create? //good question

Venkat Subramaniam

Computation Intensive vs IO Intensive

Jeżeli wątków będzie za dużo na za małą liczbę rdzeni procesora i na za małą liczbę zadań to może się okazać, że wątki będą oddawały cały czas swój czas procesora na rzecz innego wątku zamiast wykonywać swoje zadanie. Może to być bardzo nieoptymalne. W przypadku jeśli chodzi o Computation problem (czyli złożony problem obliczeniowy) wątków powinno być: liczba wątków <= liczba rdzeni procesora. W przypadku operacji IO dany jest następujący wzór: liczba wątków <= liczba rdzeni procesora / 1 – blocking factor. Blocking factor to liczba z zakresu 0 <= blocking factor < 1. W przypadku kiedy będzie 1 mamy deadlock. Więc w parallel streamie który działa w oparciu o ForkJoinPool można sobie zwiększyć liczbę wątków w CommonPool ale nie jest to zalecane bo może zmniejszyć wydajność. Rozmiar CommonPoola można ustawiać flagą:

-Djava.util.concurrent.ForkJoinPool.common.parallelism=100

wtedy wątki w parallel streamie będą się “batchować” po 100, normalnie batchują sie po standardowym rozmiarze tego common poola + główny wątek (czyli najpewniej łącznie tyle ile jest rdzeni procesora). Można też to zrobić programowo:

ForkJoinPool pool = new ForkJoinPool(100);
pool.submit(() -> stream.forEach(e -> {}));
pool.shutdown();
pool.awaitTermination(10, SEC); //tego nie musi być

Więc jak widać stream wykonuje się nie w tej puli wątków gdzie został stworzony a tam gdzie została wywołana operacja terminalna. Więc można wrzucić taki .parallelStream() do takiego ForkJoinPoola i zmusić programowo do zwiększenia liczby wątków. CO NIE ZNACZY, ŻE BĘDZIE SZYBCIEJ! WRĘCZ ODWROTNIE!

Jak zdecydować kiedy użyć parallel?

  • Jak jest dużo obiektów – ma to sens, jak jest mało obiektów – nie ma sensu
  • Jak coś się bardzo szybko wywołuje to też nie ma sensu bo nic to nie da, tylko będzie marnowało zasoby (parallel nie ogląda się na zasoby wcale)

Kiedy parallel nie ma też sensu?

Venkat rozważył taką sytuację, filtrował listę osób po płci i wieku. Jeśli chciał użyć .findFirst() to nie miało znaczenia czy użyje sekwencyjnego streama czy parallelizowanego bo ta metoda jest domyślnie ordered. Jeśli natomiast użył .findAny() to mogą być różne wyniki a nie pierwszy jak w przypadku sekwencyjnego streama. Metoda findFirst() bardziej zużywa zasoby jeśli stream jest parallelizowany, ponieważ wątki odpytują każdy rekord w liście (sprawdzając czy spełnia warunek) a nie po kolei jak w sekwencyjnym wywołaniu. Więc złożoność czasowa, może i jest na poziomie 1 ale za to marnuje się więcej pamięci żeby to osiągnąć.

Parallelizm nie martwi się zasobami, na koszt szybszego wywołania.

Zachęcam do obejrzenia całego wykładu oraz do przeczytania streszczenia drugiej części, którą można znaleźć tutaj: Streszczenie: Parallelizm i programowanie asynchroniczne ze Stream API oraz CompletableFuture cz. 2

Jeżeli coś się nie zgadza lub jest jakiś błąd merytoryczny to proszę o kontakt w celu poprawy treści. Uczę się więc mogłem coś lekko pomylić lub czegoś nie do końca zrozumieć w kontekście tego co autor miał na myśli.

Notatka: Cachowanie w Springu

Cachowanie – zapisywanie danych lub wyników jakiejś operacji (możliwe że długotrwałej lub łączącej się z bazą danych) w pamięci o lepszych parametrach w celu optymalizacji dostępu do danych (w najbliższym czasie) oraz odciążenia zasobów.

W samym hibernate istnieją trzy rodzaje cache: cache I poziomu (na poziomie sesji), cache II poziomu (na poziomie SessionFactory) oraz query cache (do cachowania zapytań do bazy danych).

Oprócz tego istnieje także Spring Cache, czyli cache na poziomie aplikacji.

Aby użyć cache w Springu należy (używając Spring Boot):

  • pobrać zależnośći do projektu – Spring Cache oraz zewnętrznego providera, jednego z dostępnych który implementuje standard JSR-107 (inaczej JCache). Dostępni providerzy to: EhCache3, HazelCast, Caffeine. Można tez korzystać z Redisa, ja skorzystam z EhCache3.
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
  <groupId>org.ehcache</groupId>
  <artifactId>ehcache</artifactId>
  <version>${ehcache.version}</version>
</dependency>
  • dodać properties ze ścieżką konfiguracyjną do EhChache3 do application.properties (lub yaml jak ktoś woli)
spring.cache.ehcache.config=classpath:ehcache.xml
  • dodać plik konfiguracyjny (który nazwałem ehcache.xml) do folderu resources, nagłówki xml można skopiować z dokumentacji EhCache3. Można też skonfigurować providera programowo
<cache-template name="default">
  <key-type>java.lang.Long</key-type>
  <expiry>
    <ttl unit="minutes">30</ttl>
  </expiry>
  <resources>
    <heap unit="entries">1000</heap>
    <offheap unit="MB">500</offheap>
  </resources>
</cache-template>
<cache alias="findAllBooks" uses-template="default"/>
<cache alias="findBookById" uses-template="default"/>
<cache alias="findAllPosts">
  <expiry>
    <ttl unit="minutes">10</ttl>
  </expiry>
  <heap unit="entries">500</heap>
</cache>

Jak widać można użyć cache-template i predefiniować sobie ustawienia lub dla każdego cache użyć innych ustawień jak np. czas po którym wpis z cache ma zostać usunięty.

  • użyć adnotacji @Cacheable na metodzie którą chce się cachować, np. w taki sposób:
@Transactional
@Cacheable(cacheNames = "findBookById", key = "#id")
public Book findById(Long id) {
    return bookRepository.findById(id).orElseThrow(
exceptionHelper.getEntityNotFoundException(id, Book.class));
}

I już, meotda (o nazwie cache która powinna być taka sama zarówno na metodzie jak i w pliku konfiguracyjnym) będzie cachowana po kluczu id!

Dodatkowe proste metody to:

@CachePut(cacheNames = "findBookById", key = "#result.id")

który służy do update wpisu w cache po danym kluczu, oraz

@CacheEvict(cacheNames = "findBookById")

która czyści cache o danej nazwie (można ustawić np. na metodzie usuwającej rekord z bazy danych).

Problem z @CachePut pojawia się jeśli chce się updatować całą kolekcję, nie ma wbudowanej adnotacji (z tego co mi wiadomo) i trzeba sobie radzić samemu. Ja po zasięgnięciu opinii ze StackOverFlow postanowiłem po prostu czyścić cache listy po update jednego rekordu. Może jest to trochę nadmiarowe jednak wydaję sie, że poprawne. Aby to zrobić trzeba wstrzyknąć CacheManager cachemanager; a następnie wyczyścić cache metodą: cacheManager.getCache("findAllBooks").clear();

Narazie to tyle ile wiem na temat cache springowego, jeśli dowiem się coś więcej będę dopisywał w tym poście.

Linki:

Dokumentacja EhCache3: https://www.ehcache.org/documentation/3.0/

Cachowanie w Spring Boot: https://www.youtube.com/watch?v=lWv3uBLO2LU

Usuwanie wrażliwego pliku z repozytorium

Jakiś czas temu, dokładniej kilka miesięcy wstecz, tworząc projekt – dodałem sobie niechciany plik do commita. Był to sam plik application.properties. Pech chciał że w owych propertisach znajdował sie mój adres email razem z hasłem (sic!). Był potrzebny do wysyłania maili z poziomu aplikacji (przez smtp googla). Nie zauważyłem tego od razu tylko dopiero po jakimś czasie. Usunąłem więc plik z Githuba i wróciłem do pracy. Niedawno zauważyłem, że może i pliku nie ma jako takiego ale to przecież Git (system do zarządzania zmianami) więc właściwości pliku nadal znajdują się na githubie (dokładniej w historii commitów). Wziąłem się więc za szukanie rozwiązania i znalazłem w dokumentacji githuba: https://docs.github.com/en/github/authenticating-to-github/removing-sensitive-data-from-a-repository. Ja zrobiłem to tak:

  • pobrałem .jar programu bfg-repo-cleaner
  • dla ułatwienia przeniosłem sobie go folderu projektu
  • musiałem jeszcze dodać zmienne środowiskowe dla javy (laptop był po gruntownym restarcie więc ich nie było)
  • uruchomiłem program z flagą --delete-files i voila!

java -jar bfg-1.13.2.jar --delete-files application.properties

Następnie należało jeszcze wykonać git push --force w katalogu projektu aby git przesłał zmiany do githuba.

Jak widać program znalazł 26 commitów w których była jakaś wzmianka na temat pliku:

Oczywiście sam program posiada wiele innych możliwości czyszczenia jak np. usuwanie plików z daną nazwą, usuwanie plików większych niż dany rozmiar, zamiana linii na inny tekst (w pliku). Tak więc mogłem po prostu zamienić frazy ze słowem email i password z application.properties zamiast usuwać cały plik ze zdalnego repozytorium. Po więcej odsyłam do dokumentacji projektu:

https://rtyley.github.io/bfg-repo-cleaner/


Close Bitnami banner
Bitnami