Лабораторна робота 6

Метапрограмування. Багатопотоковість

1 Завдання на лабораторну роботу

1.1 Індивідуальне завдання

Створити програму графічного інтерфейсу користувача, яка призначена для побудови графіку довільних функцій. Користувач повинен увести дійсні значення a і b, функції f(x) і g(x) у вигляді рядків, які відповідають синтаксису Java. У програмі здійснюється обчислення функції h(x) відповідно до індивідуального завдання:

Номери варіантів
Функція h(x)
Номери варіантів
Функція h(x)
1
13
af(x) – b∙g(x)
7
19
f(a + x) + b∙g(x)
2
14
f(x + a) + g(x – b)
8
20
f(a / x) – g(b∙x)
3
15
(a-f(x))(b + g(x))
9
21
f(x – a) ∙g(x + b)
4
16
f(a∙x) – g(b∙x)
10
22
f(a / x) + g(b / x)
5
17
f(x / a)∙ g(x + b)
11
23
a∙f(x) + b∙g(x)
6
18
f(a / x) – g(b / x)
12
24
af(x)g(b∙x)

Після введення необхідних функцій, діапазону відображення графіку і натиснення відповідної кнопки здійснюється побудова графіку. Слід також передбачити функцію очищення рядків уведення й графіку.

Для програмної реалізації обробки введених виразів слід застосувати динамічну компіляцію коду. Для створення застосунку графічного інтерфейсу користувача слід використати засоби JavaFX. Рекомендований підхід - використання компоненту LineChart.

1.2 Створення застосунку графічного інтерфейсу користувача для отримання простих множників чисел

За допомогою засобів JavaFX розробити застосунок графічного інтерфейсу користувача, в якому користувач вводить діапазон чисел (від і до), а у вікні відображаються числа і їх прості множники. Реалізувати можливість призупинення, відновлення потоку, а також повного припинення і повторного обчислення з новими даними.

1.3 Робота з BlockingQueue

Створити консольну програму, в якій один потік виконання додає цілі числа до черги BlockingQueue,а інший обчислює їх середнє арифметичне.

1.4 Інтерпретація математичних виразів

Створити консольний застосунок, який дозволяє вводити математичні вирази, обчислювати й виводити результат. Вираз може складатися з констант, математичних операцій і дужок. Для реалізації використовувати засоби інтерпретації скриптів JavaScript.

Примітка. Синтаксис математичних виразів JavaScript аналогічний Java. Результат можна виводити за допомогою функції print() без створення додаткових змінних.

1.5 Обчислення π в окремому потоці виконання

Реалізувати програму обчислення π с точністю до заданого ε як суму послідовності:

Обчислення здійснювати в окремому потоці виконання. Під час виконання обчислення надавати користувачеві можливість уводити запит про кількість обчислених елементів суми.

2 Методичні вказівки

2.1 Метапрограмування

2.1.1 Загальні концепції. Засоби виконання сценаріїв

Метапрограмування – це процес створення програм, які породжують інші програми. Найважливішим елементом метапрограмування є автоматична генерація коду. Можна запропонувати два підходи – з використанням скриптових мов і з використанням компіляції "на льоту".

Багато мов сценаріїв і динамічно типізованих мов дозволяють генерувати байт-код Java, так що програми можуть виконуватися на платформі Java, так само як програми Java. Засоби пакету javax.script, доданого в Java SE 6, дозволяють інтерпретувати вирази, написані на скриптових мовах (AppleScript, Groovy, JavaScript, Jelly, PHP, Python, Ruby тощо).

Для обробки скриптів необхідно завантажити "машину скриптів" (script engine). У Java 8 машина (движок) обробки скриптів, зокрема, мовою JavaScript (Nashorn JavaScript Engine), входила у склад JDK. У новітніх версіях Java засоби "машину" обробки скриптів слід завантажувати окремо. Замість Nashorn JavaScript Engine слід застосовувати засоби GraalVM JavaScript. До файлу pom.xml у Maven-проєкті слід додати такі залежності:

<dependency>
    <groupId>org.graalvm.js</groupId>
    <artifactId>js</artifactId>
    <version>22.0.0</version>
</dependency>
<dependency>
    <groupId>org.graalvm.js</groupId>
    <artifactId>js-scriptengine</artifactId>
    <version>22.0.0</version>
</dependency>

Тепер можна інтерпретувати код, написаний на JavaScript:

package ua.inf.iwanoff.java.advanced.sixth;

import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;

public class EvalScript {

    public static void main(String[] args) throws Exception {
        ScriptEngineManager factory = new ScriptEngineManager();
        ScriptEngine engine = factory.getEngineByName("graal.js");
        engine.eval("print('Hello, World')");
    }

}

Як видно з прикладу, клас ScriptEngineManager дозволяє створити движок JavaScript, а екземпляр класу ScriptEngine здійснює інтерпретацію виразів.

Виконання прикладу призведе до виникнення попереджень, пов'язаних з необхідністю конфігурування журналу та низькою продуктивністю через використання інтерпретації. Але скрипт буде виконано. Для того, щоб прибрати непотрібні попередження, замість стандартних засобів роботи з машиною скриптів можна скористатися класами, яки надає GraalVM JavaScript. Результат, аналогічний попередньому, але без зайвих попереджень, можна отримати, створивши об'єкт типу org.graalvm.polyglot.Context:

package ua.inf.iwanoff.java.advanced.sixth;

import org.graalvm.polyglot.Context;

public class EvalScript {

    public static void main(String[] args) throws Exception {
        try (Context ctx = Context.newBuilder("js")
                .option("engine.WarnInterpreterOnly", "false")
                .build()) {
            ctx.eval("js", "print('Hello, World');");
        }
    }

}

Вираз мовою JavaScript, або його частина, може бути прочитано з потоку введення, наприклад, з клавіатури. Клас ScriptEngine також дозволяє створювати змінні скриптової мови (метод put()), викликати її функції (метод invokeFunction()) тощо.

Для передачі інформації з Java в скрипт використовується інтерфейс зв'язування Bindings:

import javax.script.Bindings;

import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;

public class BindingDemo {

    public static void main(String[] args) throws Exception {
        ScriptEngineManager factory = new ScriptEngineManager();
        ScriptEngine engine = factory.getEngineByName("graal.js");
        engine.put("a", 1);
        engine.put("b", 5);

        Bindings bindings = engine.getBindings(ScriptContext.ENGINE_SCOPE);
        Object a = bindings.get("a");
        Object b = bindings.get("b");
        System.out.println("a = " + a);
        System.out.println("b = " + b);

        Object result = engine.eval("a + b;");
        System.out.println("a + b = " + result);
    }

}

2.1.2 Динамічна генерація коду

Однією з можливостей Java є виклик Java-компілятора у вихідному коді. З цією метою використовують пакет javax.tools. Клас javax.tools.JavaCompiler забезпечує компіляцію зазначеного вихідного коду в файл .class. Створити екземпляр JavaCompiler можна за допомогою фабричного методу getSystemJavaCompiler() класу javax.tools.ToolProvider:

JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();

Припустимо, в каталозі c:\java є підкаталог (пакет) test, а в ньому – файл Test.java:

package test;

public class Test {

    public static void main(String[] args) {
        System.out.println("Hello, Dynamic Compiling!");
    }

}

За допомогою виклику функції run() об'єкта compiler можна здійснити компіляцію вихідного коду. Перші три параметри функції run() – відповідно потоки введення, виведення даних і виведення помилок. Ці потоки використовуються у процесі компіляції. Якщо значення параметрів встановити null, будуть використовуватися відповідно System.in, System.out і System.err.У четвертому і подальших параметрах вказуються аргументи й опції компіляції. У найпростішому випадку необхідно тільки вказати повне ім'я файлу з вихідним кодом. Наприклад:

compiler.run(null, null, null, "c:/java/test/Test.java");

Тепер скомпільовану програму можна завантажити на виконання. Для того, щоб завантажити клас, не зазначений у змінній CLASSPATH, необхідно використовувати спеціальний завантажувач класів – java.net.URLClassLoader. Параметр конструктора цього класу – масив об'єктів класу URL. Клас URL представляє уніфікований визначник розташування ресурсу (Uniform Resource Locator), вказівник на "ресурс" у World Wide Web. Ресурс може бути чимось простим, типу файлу або каталогу, або це може бути посилання на більш складний об'єкт, такий як запит до бази даних або до пошукової системи. У нашому випадку такий масив буде складатися з одного елемента. Отримати такий елемент можна з об'єкта типу java.net.URI (уніфікований ідентифікатор ресурсів, Uniform Resource Identifier), який, своєю чергою, може бути отриманий з об'єкта java.io.File:

URLClassLoader classLoader = new URLClassLoader(
    new URL[] { new File("c:/java").toURI().toURL() });

Отриманий об'єкт classLoader можна використовувати для завантаження класу:

Class<?> cls = Class.forName("test.Test", true, classLoader);

Викликаємо метод main():

Method m = cls.getMethod("main", String[].class);
m.invoke(null, new Object[] { new String[] { } });

Примітка: як видно з прикладу, для того, щоб передати параметр-масив, треба зробити інший масив типу Object з масивів.

Весь код, який включає компіляцію та виконання скомпільованої програми, буде таким:

package ua.inf.iwanoff.java.advanced.sixth;

import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
import java.io.File;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;

public class CompileDemo {
    public static void main(String[] args) throws Exception {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        try (URLClassLoader classLoader = new URLClassLoader(
                new URL[]{new File("c:/java").toURI().toURL()})) {
            compiler.run(null, null, null, "c:/java/test/Test.java");
            Class<?> cls = Class.forName("test.Test", true, classLoader);
            Method m = cls.getMethod("main", String[].class);
            m.invoke(null, new Object[]{new String[]{}});
        }
    }
}

У складнішому випадку крім класу JavaCompiler необхідно залучати інші засоби пакету javax.tools. Зокрема:

  • JavaCompiler.CompilationTask – клас, який представляє завдання компіляції; виконання компіляції здійснюється методом call();
  • JavaFileManager управляє читанням і записом у файли при компіляції;
  • JavaFileObject – файловий об'єкт, який дозволяє абстрагуватися від вихідного коду Java і файлів класів;
  • DiagnosticListener – слухач діагностичних подій компіляції.

Якщо сирцевий код береться не з файлової системи, то необхідно створити клас, який реалізує інтерфейс JavaFileObject. Java надає просту реалізацію у вигляді класу SimpleJavaFileObject. Можна створити похідний клас і перекрити методи відповідно до необхідності.

2.2 Робота з потоками виконання. Загальні концепції

Процес (process) – це екземпляр комп'ютерної програми, яка завантажена в пам'ять виконується. В сучасних операційних системах процес може виконуватися паралельно з іншими процесами. Процесу виділяється окремий адресний простір, причому цей простір фізично недоступний для інших процесів.

Більшість реалізацій віртуальної машини Java виконується у межах одного процесу. Програма Java може створити додаткові процеси та організувати взаємодію між ними за допомогою об'єкта ProcessBuilder. У наведеному нижче найпростішому прикладі здійснюється запуск програми notepad.exe (блокнот):

package ua.inf.iwanoff.java.advanced.sixth;

public class StartNotepad {

    public static void main(String [] args) throws java.io.IOException {
        String[] command = {"notepad"}; // у масиві також можуть бути аргументи
        ProcessBuilder processBuilder = new ProcessBuilder(command);
        processBuilder.start();
    }
  
}

Нитка (thread), або потік виконання (потік управління) – це окрема підзадача, яка може виконуватися паралельно з іншими підзадачами (нитками) в межах одного процесу. Кожен процес містить як мінімум один потік виконання, іменований головним (main thread). Всі потоки виконання, створені процесом, виконуються в адресному просторі цього процесу і мають доступ до ресурсів процесу. Створення потоків виконання – істотно менш ресурсомістка операція, ніж створення нових процесів. Потоки виконання іноді називають легковажними процесами (lightweight processes).

Якщо процес створив кілька потоків, то всі вони виконуються паралельно, при чому час центрального процесора (або декількох центральних процесорів в мультипроцесорних системах) розподіляється між цими потоками. Розподілом часу центрального процесора займається спеціальний модуль операційної системи – планувальник (scheduler). Планувальник по черзі передає управління окремим потокам, так що навіть в однопроцесорній системі створюється ілюзія паралельної роботи запущених потоків. Розподіл часу виконується за перериваннями системного таймера.

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

2.3 Низькорівневі засоби роботи з потоками виконання

2.3.1 Створення потоків виконання

Є два підходи до створення об'єкта-нитки з використанням класу Thread:

  • успадкування нового класу від класу java.lang.Thread і створення об'єкта нового класу;
  • створення класу, який реалізує інтерфейс java.lang.Runnable; об'єкт такого класу передається конструктору класу java.lang.Thread.

Під час створення похідного класу від Thread необхідно перекрити його метод run(). Після створення об'єкта-потоку його треба запустити за допомогою методу start(). Цей метод здійснює певну ініціалізацій роботу і викликає run(). У наведеному нижче прикладі створюється окремий потік виконання, який здійснює виведення чергового цілого числа від 1 до 40:

package ua.inf.iwanoff.java.advanced.sixth;

public class ThreadTest extends Thread {
  
    @Override
    public void run() {
        for (int counter = 1; counter <= 40; counter++) {
            try {
                Thread.sleep(1000);
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(counter);
        }
    }

    public static void main(String[] args) {
        new ThreadTest().start();
        // Сюди можна додати дії, що виконуються паралельно з методом run()
    }

}

Виклик методу sleep() зумовлює припинення потоку на вказану кількість мілісекунд. Виклик методу sleep() вимагає перехоплення винятку InterruptedException, який генерується в разі переривання цього потоку виконання іншим потоком. Аналогічний приклад зі створенням об'єкта класу, що реалізує інтерфейс Runnable. У цьому випадку також необхідно запустити потік за допомогою методу start():

package ua.inf.iwanoff.java.advanced.sixth;

public class RunnableTest implements Runnable {
  
    @Override
    public void run() {
        for (int counter = 1; counter <= 40; counter++) {
            try {
                Thread.sleep(1000);
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(counter);
        }
    }  

    public static void main(String[] args) {
        new Thread(new RunnableTest()).start();
        // Сюди можна додати дії, що виконуються паралельно з методом run()
    }

}

Другий підхід є кращим, оскільки в цьому випадку ми вільні у виборі базового класу.

Окремим потокам можна давати імена (другий параметр конструктора) і отримувати ці імена за допомогою функції getName().

За допомогою методу setPriority()можна змінювати пріоритети потоків. Не рекомендується встановлювати вищий пріоритет (константа Thread.MAX_PRIORITY).

2.3.2 Стани потоку

Будь-який потік виконання може знаходитися в декількох стандартних станах. Стан "новий" (Thread.State.NEW) потік отримує, коли створюється об'єкт потоку. Виклик методу start() переводить потік зі стану "новий" в стан "працює" (Thread.State.RUNNABLE). Існують стани "заблокований" (Thread.State.BLOCKED), "заблокований за часом", або "в режимі очікування" (Thread.State.TIMED_WAITING), "очікує", або "непрацездатний" (Thread.State.WAITING) і "завершений" (Thread.State.TERMINATED). Під час створення потоку він отримує стан "новий" і не виконується. Отримати значення стану потоку можна викликом методу getState(). Наведений нижче приклад демонструє деякі стани потоку в межах його життєвого циклу:

package ua.inf.iwanoff.java.advanced.sixth;

public class StateTest {

    public static void main(String[] args) throws InterruptedException {
        Thread testThread = new Thread();
        System.out.println(testThread.getState()); // NEW
        testThread.start();
        System.out.println(testThread.getState()); // RUNNABLE
        testThread.interrupt(); // перериваємо потік
        Thread.sleep(100);      // потрібен час для завершення потоку
        System.out.println(testThread.getState()); // TERMINATED
    }
}

Метод wait(long timeout), як і метод sleep(long timeout), дозволяє призупинити роботу потоку на вказану кількість мілісекунд. Під час виконання цього методу також може бути згенеровано виняток InterruptedException. Застосування цих методів дозволяє перевести потік в режим очікування (TIMED_WAITING). На відміну від sleep(), працездатність після виклику методу wait() можна відновити методами notify() або notifyAll().

Метод wait() може бути викликаний без параметрів. Потік при цьому переходить в стан "непрацездатний" (WAITING).

Менш надійним альтернативним способом припинення роботи потоку є виклик методу yield(), який повинен призводити до припинення потоку на деякий квант часу, для того, щоб інші потоки могли виконувати свої дії.

Після завершення виконання методу run() потік закінчує свою роботу, потік переходить в пасивний стан (TERMINATED).

У ранніх версіях Java для примусової зупинки, а також для тимчасового призупинення роботи потоку з подальшим відновленням передбачалося використання методів класу Thread - stop(), suspend() і resume(). Методи stop(), suspend() і resume() вважаються небажаними для використання (deprecated-методи), оскільки вони провокують створення ненадійного коду, який важко зневаджувати. Крім того, використання suspend() і resume() може спровокувати взаємні блокування (deadlock).

Перервати потік, який знаходиться в стані очікування (Thread.State.TIMED_WAITING), можна за допомогою виклику функції interrupt(). В цьому випадку генерується виняток InterruptedException. Можна модифікувати попередній приклад так, щоб він дозволяв перервати потік виконання:

package ua.inf.iwanoff.java.advanced.sixth;

import java.util.Scanner;

public class InterruptTest implements Runnable {

    @Override
    public void run() {
        for (int counter = 1; counter <= 40; counter++) {
            try {
                Thread.sleep(1000);
            }
            catch (InterruptedException e) {
                System.out.println("Потік перерваний.");
                break;
            }
            System.out.println(counter);
        }
    }

    public static void main(String[] args) {
        Thread thread = new Thread(new InterruptTest());
        thread.start();
        System.out.println("Для переривання натисніть Enter");
        new Scanner(System.in).nextLine();
        thread.interrupt();
    }

}

2.3.3 Об'єднання та переривання потоків

Метод join() дозволяє одному потоку дочекатися завершення іншого. Виклик цього методу всередині потоку t1 для потоку t2 призведе до припинення поточного потоку (t1) до завершення t2, як показано в наведеному нижче прикладі:

package ua.inf.iwanoff.java.advanced.sixth;

public class JoinTest {
    static Thread t1, t2;
  
    static class FirstThread implements Runnable {
        @Override
        public void run() {
            try {
                System.out.println("Потік First запущений.");
                Thread.sleep(1000);
                System.out.println("Основна робота потоку First завершена.");
                t2.join();
                System.out.println("Потік First завершено.");
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    static class SecondThread implements Runnable {
        @Override
        public void run() {
            try {
                System.out.println("Потік Second запущений.");
                Thread.sleep(3000);
                System.out.println("Потік Second завершено.");
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        t1 = new Thread(new FirstThread());
        t2 = new Thread(new SecondThread());
        t1.start();
        t2.start();
    }

}

Порядок виведення повідомлень буде таким:

Потік First запущений.
Потік Second запущений.
Основна робота потоку First завершена.
Потік Second завершено.
Потік First завершено.

Як і sleep(), join() реагує на переривання, отже, необхідно перехоплювати виняток InterruptedException.

Можна створити потік виконання, робота якого переривається при завершенні потоку, який його породив. Для того, щоб створити такий потік, викликається метод setDaemon setDaemon() з параметром true. Метод setDaemon() необхідно викликати після створення потоку, але до моменту його запуску, тобто перед викликом методу start().За допомогою методу isDaemon() можна перевірити, є потік демоном, чи ні. Якщо потік-демон створює інші потоки, то вони також отримають статус потоку-демона.

package ua.inf.iwanoff.java.advanced.sixth;

public class DaemonThread extends Thread {
    public void run() {
        try {
            if (isDaemon()) {
                System.out.println("старт потоку-демона");
                sleep(1000);
            }
            else {
                System.out.println("старт звичайного потоку");
            }
        }
        catch (InterruptedException e) {
            System.err.print("Error" + e);
        }
        finally {
            if (!isDaemon())
                System.out.println("завершення звичайного потоку");
            else
                System.out.println("завершення потоку-демона");
        }
    }

    public static void main(String[] args) {
        DaemonThread usual = new DaemonThread();
        DaemonThread daemon = new DaemonThread();
        daemon.setDaemon(true);
        daemon.start();
        usual.start();
        System.out.println("останній рядок main");
    }
}

Після компіляції й запуску, можливо, буде виведено:

старт потоку-демона
останній рядок main
старт звичайного потоку
завершення звичайного потоку

Потік-демон (через виклик методу sleep(10000)) не встиг завершити виконання свого коду до завершення основного потоку застосунку, пов'язаного з методом main(). Базова властивість потоків-демонів полягає в можливості основного потоку застосунку завершити виконання з закінченням коду методу main(), не звертаючи уваги на те, що потік-демон ще працює. Якщо поставити час затримки також для потоку main(), то потік-демон може встигнути завершити своє виконання до закінчення роботи основного потоку:

старт потоку-демона
старт звичайного потоку
завершення звичайного потоку
завершення потоку-демона
останній рядок main

2.3.4 Синхронізація потоків

Якщо запущено декілька потоків виконання, потрібен спосіб для виключення можливості використання одного ресурсу двома потоками. Якщо елементи даних класу оголошені як private і доступ до цієї області пам'яті можливий тільки за допомогою методів, то можна уникнути колізій, оголосивши ці методи як synchronized. Одночасно тільки один процес може викликати synchronized метод для певного об'єкта. Такий метод або фрагмент коду іменується критичною секцією.

Для забезпечення доступу до такого коду використовується концепція монітора. Під монітором розуміють деякий об'єкт, що забезпечує блокування коду під час виконання його деяким потоком. Якщо ключове слово synchronized розташоване перед заголовком функції, монітором є об'єкт, для якого викликаний цей метод (this). Після того, як викликаний хоча б один синхронізований метод, блокування поточним об'єктом поширюється на всі методи з модифікатором synchronized.

Розглянемо такий приклад. Клас Adder дозволяє додавати цілі числа до деякого накопичувача. Клас AdderThread реалізує потік виконання, що забезпечує додавання послідовних п'яти цілих значень. У функції main() класу AdderTest створюємо два потоки й запускаємо їх:

package ua.inf.iwanoff.java.advanced.sixth;

class Adder {
    long sum = 0;

    public void add(long value) {
        this.sum += value;
        System.out.println(Thread.currentThread().getName() + " " + sum);
    }
}

class AdderThread extends Thread {
    private Adder counter = null;

    public AdderThread(String name, Adder counter) {
        super(name);
        this.counter = counter;
    }

    public void run() {
        try {
            for (int i = 0; i < 5; i++) {
                counter.add(i);
                Thread.sleep(10);
            }
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class AdderTest {

    public static void main(String[] args) {
        Adder adder = new Adder();
        Thread threadA = new AdderThread("A", adder);
        Thread threadB = new AdderThread("B", adder);
        threadA.start();
        threadB.start();
    }

}

Завантажена декілька разів на виконання програма буде продукувати різні проміжні результати, що пов'язано з некоректною роботою двох потоків з одним об'єктом. Для виправлення ситуації перед методом add() слід додати модифікатор synchronized:

  synchronized public void add(long value) {
      this.sum += value;
      System.out.println(Thread.currentThread().getName() + " " + sum);
  }

Іноді оголошувати весь метод як synchronized незручно, оскільки одночасно інші нитки могли б виконувати інші заблоковані методи. Для розв'язання цієї проблеми використовують блок, що починається зі слова synchronized і містить після цього слова в дужках ім'я об'єкта, який відповідає за блокування. Монітором може бути будь-який об'єкт, похідний від java.lang.Object, що підтримує необхідні для цього методи. У наведеному нижче прикладі створюється спеціальний об'єкт для блокування:

  public void add(long value) {
      Object lock = new Object();
      synchronized (lock) {
          this.sum += value;
          System.out.println(Thread.currentThread().getName() + " " + sum);
      }
  }

Примітка: в нашому випадку такий підхід може не дати бажаного результату; краще здійснити блокування на рівні всього методу.

Синхронізації не вимагають процеси запису і читання об'єктів, розміри яких не перевищують 32 біт. Такі об'єкти називаються атомарними.

Під час обміну даних через поля слід використовувати ключове слово volatile перед описом відповідного поля. Це пов'язано з наявністю механізму кешування даних окремо для кожного потоку, і значення з цього кешу може відрізнятися для кожного з них. Оголошення поля з ключовим словом volatile відключає для нього таке кешування:

volatile long sum = 0;

Метод wait(), викликаний всередині синхронізованого блоку або методу, зупиняє виконання поточного потоку і звільняє від блокування захоплений об'єкт, зокрема об'єкт lock. Повернути блокування об'єкта потоку можна викликом методу notify() для конкретного потоку або notifyAll() для всіх потоків. Виклик може бути здійснений тільки з іншого потоку, який заблокував, своєю чергою, зазначений об'єкт.

2.4 Високорівневі засоби роботи з потоками виконання

2.4.1 Недоліки низькорівневих засобів підтримки багатопотоковості. Огляд високорівневих засобів

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

  • тенденція до перемішування низькорівневого коду організації багатопотоковості з кодом, що відповідає за основну функціональність програми
  • неможливість перезапуску потоку, що зумовлює необхідність створення зайвих об'єктів
  • перевитрата динамічної пам'яті у зв'язку з особливостями розміщення об'єктів-потоків
  • складності в управлінні ресурсами, що розділяються
  • проблеми з недостатньою ефективністю synchronized-коду
  • відсутність ефективних методів боротьби із взаємними блокуваннями.

Особливо перелічені недоліки є відчутними при створенні великої кількості потоків виконання.

Починаючи з версії Java 5 програміст має можливість працювати з високорівневими засобами маніпулювання потоками виконання. Більшість із цих можливостей представлено в новому пакеті java.util.concurrent та вкладених у нього пакетах. До основних засобів пакета можна віднести такі:

  • робота з одиницями виміру часу
  • робота з атомарними операціями
  • управління створенням потоків
  • управління блокуваннями
  • управління ресурсами, що розділяються

Крім того, версії Java 7 додані засоби розгалуження та об'єднання потоків (fork/join framework). Ці засоби були розроблені спеціально для розпаралелювання рекурсивних завдань. Абстрактний клас ForkJoinTask є "легковажнии" аналогом потоку виконання. У поєднанні з класом ForkJoinPool цей клас дозволяє визначити велику кількість паралельних завдань, що істотно перевищує кількість реальних потоків виконання (threads).

2.4.2 Робота з переліком TimeUnit

Перелік java.util.concurrent.TimeUnit використовують для представлення одиниць виміру проміжків часу. Він реалізує утиліти для перетворення часу з одних одиниць на інші. У наведеному нижче прикладі демонструються деякі можливості перетворення одиниць часу:

package ua.inf.iwanoff.java.advanced.sixth;

import java.util.concurrent.TimeUnit;

public class TimeTest {
    public static void main(String[] args) {
        // Отримуємо час роботи Java VM (в наносекундах):
        long nanos = System.nanoTime();
        System.out.println(nanos);
        long micros = TimeUnit.NANOSECONDS.toMicros(nanos);
        System.out.println(micros);
        long millis = TimeUnit.MICROSECONDS.toMillis(micros);
        System.out.println(millis);
        long seconds = TimeUnit.MILLISECONDS.toSeconds(millis);
        System.out.println(seconds);
        long minutes = TimeUnit.SECONDS.toMinutes(seconds);
        System.out.println(minutes);
        long hours = TimeUnit.NANOSECONDS.toMillis(minutes);
        System.out.println(hours);
        long days = TimeUnit.HOURS.toDays(hours);
        System.out.println(days);
    }
}

Існує також універсальний метод convert() із двома параметрами – тривалістю та одиницею вимірювання часу, що дозволяє виконати аналогічні дії.

Цей перелік використовується переважно в контексті багатопотоковості. У переліку, зокрема, визначено методи timedWait(), timedJoin() та sleep(). Наприклад,

TimeUnit.SECONDS.sleep(2);

забезпечує "засипання" на 2 секунди. Як і для Thread.sleep(), потрібно перехоплення винятку InterruptedException.

2.4.3 Робота з атомарними операціями

Для того, щоб уникнути блокування, пакет java.util.concurrent.atomic надає так звані атомарні операції. Вони представлені класами AtomicBoolean, AtomicInteger, AtomicLong і AtomicReference. Для класів, що представляють атомарні операції над цілими, реалізовано ряд методів, найбільш уживані з яких – set(), getAndSet(), compareAndSet(), incrementAndGet(), decrementAndGet(), incrementAndGet(), decrementAndGet(). Відповідні дії над змінними виконуються із забезпеченням безпеки, але без застосування блокувань. У наведеному нижче прикладі здійснюється робота з AtomicLong:

package ua.inf.iwanoff.java.advanced.sixth;

import java.util.concurrent.atomic.AtomicLong;

public class AtomicLongDemo {
    public static void main(String[] args) {
        AtomicLong result = new AtomicLong(1);
        for (AtomicLong index = new AtomicLong(1); index.get() <= 20;
                                          index.incrementAndGet()) {
            result.set(result.get() * index.get());
        }
        System.out.println(result);
    }
}

Як видно з прикладу, здійснюється обчислення факторіала 20.

2.4.4 Управління створенням потоків. Пули потоків

Під час створення великих застосунків доцільно відокремити створення та керування потоками від коду іншої програми. Для цього оголошено інтерфейси Executor, ExecutorService і ScheduledExecutorService, які декларують можливості реалізації створення пулів черг. Узагальнений інтерфейс Callable представляє деяку функцію та аналогічний Runnable, але дозволяє повертати деякий результат та генерувати винятки. Узагальнений інтерфейс Future використовують для представлення результату асинхронної операції submit(), визначеної в ExecutorService.

Для того, щоб скористатися можливосятми пулу потоків, слід створити об'єкт класу ExecutorService. Найкращий варіант створення – скористатися одним із фабричних методів класу Executors.

У наведеному нижче прикладі створюються три потоки виконання, в яких виводяться відповідні повідомлення:

// Параметр - кількість потоків у пулі:
ExecutorService service = Executors.newFixedThreadPool(3); 
service.execute(() -> System.out.println("First"));
service.execute(() -> System.out.println("Second"));
service.execute(() -> System.out.println("Third"));

Для коректної зупинки потоків слід скористатися функцією shutdown():

service.shutdown();

Запуск коду може приводити до отримання повідомлень у різній послідовності.

2.4.5 Високорівневі засоби керування блокуваннями

Одне з головних завдань високорівневих засобів багатопотоковості полягає в управлінні блокуваннями, що забезпечує виключення ситуації, пов'язаної із взаємним блокуванням.

Інтерфейс java.util.concurrent.locks.Lock підтримує високорівневі засоби керування блокуванням. Клас java.util.concurrent.locks.ReentrantLock, що реалізує даний інтерфейс, підтримує обмежені очікування зняття блокування, спроби блокування, що перериваються, черги блокування й установку очікування зняття декількох блокувань. Використання об'єктів блокування замість низькорівневих засобів, зокрема, запобігає взаємним блокуванням (deadlocks).

Зокрема, так можна встановити блокування (запобігти виконанню фрагмента коду для інших ниток) за допомогою об'єкта класу java.util.concurrent.locks.ReentrantLock (альтернатива synchronized). У наведеному нижче прикладі гарантується проведення операції зі зняття коштів з рахунку лише одним із клієнтів.

private Lock accountLock = new ReentrantLock();
  
public void withdrawCash(int accountID, int amount) {
    // Тут може бути потоковобезпечний код,
    // наприклад, читання з файлу чи бази даних

    accountLock.lock(); // заблокувати, коли сюди потрапляє потік  
    try {
        if (allowTransaction) { // якщо дозволено транзакцію
            updateBalance(accountID, amount, "Вилучення"); // зміна балансу
        }
        else {
            System.out.println("Недостатньо коштів");
        }
    }
    finally {
        accountLock.unlock(); // дозволити іншим потокам змінювати баланс
    }
}

Метод tryLock() намагається негайно заблокувати екземпляр блокування. Цей метод повертає true, якщо блокування пройшло успішно, і false, якщо екземпляр вже заблокований. Можна використовувати час затримки блокування та одиницю виміру:

Lock lock = ...;
if (lock.tryLock(50L, TimeUnit.MILLISECONDS)) ...

2.4.6 Використання синхронізаторів

Синхронізатори є набором допоміжних класів для синхронізації потоків. Найчастіше використовують такі синхронізатори:

  • Semaphore (Семафор) використовується для обмеження кількості потоків під час роботи з певними ресурсами. Доступ до загального ресурсу управляється за допомогою лічильника. Якщо лічильник більше нуля, доступ потоку дозволяється, а значення лічильника зменшується. Якщо лічильник дорівнює нулю, поточний потік блокується, поки інший потік не звільнить ресурс.
  • CountDownLatch (замок зі зворотним відліком) дозволяє одному або декільком потокам чекати доти, доки не завершиться певна кількість операцій, що виконують в інших потоках. У конструкторі класу вказується кількість допустимих операцій. Відповідні операції повинні забезпечувати зменшення лічильника викликом методу countDown(). Коли лічильник досягає 0, всі потоки, що очікують, розблокуються і продовжують виконуватися.
  • CyclicBarrier (Циклічний бар'єр) являє собою точку синхронізації, в якій задана кількість паралельних потоків зустрічається і блокується. Як тільки всі потоки прибули, виконується деяка дія (або не виконується, якщо бар'єр був ініціалізований без неї), і після того, як її виконано, потоки, що очікують, звільняються.
  • Phaser аналогічний циклічному бар'єру, але є гнучкішим і надає більшу функціональність.
  • Exchanger (Обмінник) дозволяє обмінюватися даними між двома потоками в певній точці роботи. Обмінник є узагальненим класом, що параметризується типом об'єкта для передачі. Потік, що викликає метод exchange() обмінника, блокується і чекає на інший потік. Коли інший потік викликає той самий метод, відбудеться обмін об'єктами.

2.5 Використання потоків виконання у JavaFX-застосунках

Робота застосунків графічного інтерфейсу користувача зазвичай пов'язана з потоками виконання. Після завантаження JavaFX-застосунку на виконання автоматично створюється потік застосунку (application thread), в якому здійснюється обробка подій. Тільки цей потік може мати взаємодію з вікнами й візуальними компонентами. Спроба двох потоків одночасно керувати зовнішнім виглядом компонентів та інформацією у вікні може призвести до хаотичного вигляду й непередбачуваної поведінки візуальних компонентів. Разом з тим, саме завдяки багатопотоковості можна забезпечити керованість застосунком. Крім того, доцільно деякі дії, які виконуються тривалий час, запускати в окремому потоці виконання, що забезпечить можливість відстежувати процес, призупиняти й продовжувати його, змінювати налаштування тощо.

Засоби JavaFX надають можливість виконувати код в потоці виконання, який відповідає за отримання й обробку подій. Метод javafx.application.Platform.runLater() отримує об'єкт, який реалізує функціональний інтерфейс Runnable. Цей об'єкт отримує потік обробки подій, де об'єкт стає в чергу і, коли виникає можливість, виконується його метод run(). Саме в цьому методі доцільно розташувати взаємодію з візуальними компонентами. Приклад 3.3 демонструє можливість виклику цього методу з дочірнього потоку.

Існують також високорівневі механізми роботи з потоками виконання у JavaFX, представлені в пакеті javafx.concurrent: абстрактний клас Task, похідний від java.util.concurrent.FutureTask, своєю чергою, передбачає створення похідного класу, який реалізує метод call(), в якому реалізована взаємодія з візуальними компонентами. Крім того, цей клас надає можливість безпосереднього зв'язування деяких компонентів (наприклад, ProgressBar) з задачею (Task).

2.6 Використання колекцій, безпечних з точки зору багатопотоковості

2.6.1 Використання синхронізованих аналогів стандартних колекцій

Засобами класу java.util.Collections можна отримати безпечні з точки зору багатопотоковості клони наявних колекцій. Це такі статичні функції, як synchronizedCollection(), synchronizedList(), synchronizedMap(), synchronizedSet(), synchronizedSortedMap() і synchronizedSortedSet(). Кожна з цих функцій приймає як параметр відповідну несинхронізовану колекцію і повертає колекцію такого ж типу, всі методи якої (крім ітераторів) визначені як synchronized. Відповідно всі операції, включаючи читання, використовують блокування, що знижує ефективність роботи й обмежує використання відповідних колекцій.

Ітератори стандартних колекцій, реалізованих у java.util, реалізують так звану fail-fast поведінку: у випадку зміни об'єкта в процесі обходу колекції ітератором генерується виняток ConcurrentModificationException, причому в умовах багатопотоковості поведінка ітератора може бути непередбачуваною – виняток може бути згенеровано не відразу або не згенеровано взагалі. Надійну і роботу з елементами колекції забезпечують спеціальні контейнерні класи, визначені в java.util.concurrent. Це такі типи, як CopyOnWriteArrayList, CopyOnWriteArraySet, ConcurrentMap (інтерфейс), ConcurrentHashMap (реалізація), ConcurrentNavigableMap (інтерфейс) і ConcurrentSkipListMap (інтерфейс).

Клас CopyOnWriteArrayList, який реалізує інтерфейс List, для всіх операцій зміни значення елементів створює копію масиву. Ітератор також працює зі своєю копією. Така поведінка ітератора має назву fail-safe поведінки.

Класи ConcurrentHashMap і ConcurrentSkipListMap забезпечують роботу з асоціативними масивами, яка не потребує блокування всіх операцій. Клас ConcurrentHashMap організовує структуру даних, розділену на сегменти. Блокування здійснюється на рівні одного сегмента. Якщо поліпшити хеш-функції, ймовірність звернення двох ниток до одного сегмента істотно знижується. Клас ConcurrentSkipListMap реалізує зберігання з використанням спеціальної структури даних – skip list (Список з пропусками). Елементи асоціативного масиву впорядковані за ключами. У прикладі 3.2 продемонстровано роботу з ConcurrentSkipListMap.

2.6.2 Робота зі спеціальними колекціями, які використовують блокування

Пакет java.util.concurrent надає набір колекцій, безпечних з точки зору багатопотоковості. Це такі узагальнені типи, як BlockingQueue (інтерфейс) і його стандартні реалізації (ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue, SynchronousQueue, DelayQueue), похідні від нього інтерфейси TransferQueue (реалізація LinkedTransferQueue) і LinkedBlockingDeque (стандартна реалізація – LinkedBlockingDeque). Колекції реалізовані в пакеті java.util.concurrent.

Інтерфейс BlockingQueue представляє чергу, яка дозволяє додавати й вилучати елементи безпечно з точки зору потоків. Типове використання BlockingQueue – занесення об'єктів одним потоком, а вилучення – іншим.

Потік, який додає об'єкти (перший потік), продовжує додавати їх в чергу, доки не буде досягнута деяка верхня межа припустимої кількості елементів. У цьому випадку під час спроби додати нові об'єкти перший потік блокується, поки потік виконання, який отримує елементи (другий потік) не вилучить з черги принаймні один елемент. Другий потік може вилучати й використовувати об'єкти з черги. Якщо, проте, другий потік спробує отримати об'єкт з порожньої черги, цей потік блокується, поки перший потік не додасть новий об'єкт.

Інтерфейс BlockingQueue походить від java.util.Queue, тому він підтримує методи add(), remove(), element() (з генерацією винятку), а також offer(), poll() и peek() (з поверненням спеціального значення). Крім того, визначені методи, які блокують – put() (для додавання) і take() (для вилучення). Є також методи, які використовують блокування з часовим інтервалом – offer()і poll() з додатковими параметрами (інтервалом часу й одиницею часу).

До BlockingQueue не можна додати значення null. Така спроба призводить до генерації винятку NullPointerException.

Найбільш популярна реалізація BlockingQueue – це ArrayBlockingQueue. Для зберігання посилань на об'єкти в цій реалізації використовують звичайний масив обмеженої довжини. Ця довжина після створення об'єкта не може бути змінена. Всі конструктори класу першим параметром отримують цю довжину масиву. У наведеному нижче прикладі створюються два потоки – виробник (Producer) і споживач (Consumer). Виробник додає цілі числа з затримкою між додаваннями, а споживач їх вилучає.

package ua.inf.iwanoff.java.advanced.sixth;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

class Producer implements Runnable {
    private BlockingQueue<Integer> queue;
    int countToAdd;

    public Producer(BlockingQueue<Integer> queue, int countToAdd) {
        this.queue = queue;
        this.countToAdd = countToAdd;
    }

    public void run() {
        // Намагаємося додавати числа:
        try {
            for (int i = 1; i <= countToAdd; i++) {
                queue.put(i);
                System.out.printf("Added: %d%n", i);
                Thread.sleep(100);
            }
        }
        catch (InterruptedException e) {
            System.out.println("Producer interrupted");
        }
    }
}

class Consumer implements Runnable {
    private BlockingQueue<Integer> queue;
    int countToTake;

    public Consumer(BlockingQueue<Integer> queue, int countToTake) {
        this.queue = queue;
        this.countToTake = countToTake;
    }

    public void run() {
        // Вилучаємо числа:
        try {
            for (int i = 1; i <= countToTake; i++) {
                System.out.printf("Taken by customer: %d%n", queue.take());
            }
        }
        catch (InterruptedException e) {
            System.out.println("Consumer interrupted");
        }
    }
}

public class BlockingQueueDemo {

    public static void main(String[] args) throws InterruptedException {
        // Створюємо чергу з 10 елементів:
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
    
        // Створюємо і запускаємо два потоки - для запису і читання:
        Thread producerThread = new Thread(new Producer(queue, 100));
        Thread consumerThread = new Thread(new Consumer(queue, 4));
        producerThread.start();
        consumerThread.start();
    
        // Чекаємо 10 секунд і перериваємо перший потік:
        Thread.sleep(10000);
        producerThread.interrupt();
    }
  
}

Під час запуску програми видно, що потік-виробник послідовно додає числа в чергу. Перші чотири числа вилучаються потоком-споживачем, причому потік-споживач кожен раз блокується, чекаючи наступне число. Оскільки в черзі може розміститися лише 10 чисел, після того, як потік-споживач припиняє вилучати елементи, черга швидко переповнюється. За десять секунд головний потік припиняє потік-виробник.

Реалізація LinkedBlockingQueue відрізняється від ArrayBlockingQueue внутрішнім поданням – у вигляді зв'язного списку. При цьому максимальний розмір вже не суттєвий (усталене значення – Integer.MAX_VALUE), однак він також може бути заданий в конструкторі.

Клас PriorityBlockingQueue використовує ті ж правила впорядкування, що й java.util.PriorityQueue.

Клас SynchronousQueue представляє чергу, яка може містити лише один елемент. Потік виконання, який додав елемент, блокується, поки інший потік не отримає його з черги. Якщо другий потік не може отримати елемент (черга порожня), цей потік також блокується, поки новий елемент не буде додано першим потоком.

Реалізація DelayQueue дозволяє вилучати тільки ті елементи, для яких закінчився деякий проміжок часу (затримка). Елементи цієї черги повинні реалізовувати інтерфейс java.util.concurrent.Delayed. Цей інтерфейс визначає метод getDelay(), який повертає затримку, яка залишилася для об'єкта. З черги в першу чергу вилучається елемент, для якого час затримки минув раніше інших. Якщо у жодного з елементів не закінчилася затримка, метод poll() поверне null.

Інтерфейс BlockingDeque, похідний від BlockingQueue, додатково підтримує операції addFirst(), addLast(), takeFirst() і takeLast(), характерні для черг з двома кінцями. Клас LinkedBlockingDeque пропонує стандартну реалізацію цього інтерфейсу.

2.7 Стандартні засоби створення графіків і діаграм

Стандартні засоби JavaFX надають готові компоненти для побудови графіків і діаграм. Абстратний клас Chart є базовим для всіх графіків і діаграм і об'єднує в собі властивості всіх компонентів, призначених для візуалізації числових даних. Безпосередньо похідними від нього є класи PieChart (кругова діаграма) та XYChart. Останній також є абстрактним і виступає базовим для всіх інших діаграм: AreaChart, BarChart, BubbleChart, LineChart, ScatterChart, StackedAreaChart і StackedBarChart.

Конструктори класів, похідних від XYChart, вимагають визначення двох об'єктів типу Axis (вісь). Axis – це абстрактний клас для представлення осі на графіку або діаграмі. Похідними від нього є клас CategoryAxis, об'єкти якого використовують, коли вздовж осі розташовують мітки у вигляді набору рядків, а також абстрактний клас ValueAxis. Клас NumberAxis, похідний від ValueAxis, використовують, коли вісь представляє шкалу числових значень.

У наведеному нижче прикладі здійснюється демонстрація можливостей побудови деяких діаграм:

package ua.inf.iwanoff.java.advanced.sixth;

import javafx.application.Application;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.chart.*;
import javafx.scene.chart.XYChart.Series;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;

public class ChartDemo extends Application {

    @Override
    public void start(Stage primaryStage) {
        primaryStage.setTitle("Chart Demo");

        PieChart pieChart = new PieChart();
        pieChart.setMinWidth(300);
        pieChart.setTitle("Pie Chart");
        pieChart.getData().addAll(new PieChart.Data("two", 2),
                                  new PieChart.Data("three", 3),
                                  new PieChart.Data("one", 1));

        XYChart.Data<String, Number>[] barData = new XYChart.Data[] {
                                  new XYChart.Data<>("one", 2.0),
                                  new XYChart.Data<>("two", 3.0),
                                  new XYChart.Data<>("three", 1.0) };
        BarChart<String, Number> barChart = new BarChart<>(new CategoryAxis(), new NumberAxis());
        barChart.setTitle("Bar Chart");
        barChart.getData().addAll(new Series<>("Bars", FXCollections.observableArrayList(barData)));

        XYChart.Data<Number, Number>[] xyData = new XYChart.Data[] {
                new XYChart.Data<>(1.0, 2.0),
                new XYChart.Data<>(2.0, 3.0),
                new XYChart.Data<>(3.0, 1.0) };
        AreaChart<Number, Number> areaChart = new AreaChart<>(new NumberAxis(), new NumberAxis());
        areaChart.setTitle("Area Chart");
        areaChart.getData().addAll(new Series<>("Points", FXCollections.observableArrayList(xyData)));
        // Використовує той самий набір даних
        BubbleChart<Number, Number> bubbleChart = new BubbleChart<>(
                new NumberAxis(0, 4, 1), new NumberAxis(0, 5, 1));
        bubbleChart.setTitle("Bubble Chart");
        bubbleChart.getData().addAll(new Series<>("Bubbles", FXCollections.observableArrayList(xyData)));

        HBox hBox = new HBox();
        hBox.getChildren().addAll(pieChart, barChart, areaChart, bubbleChart);
        hBox.setSpacing(10);
        hBox.setPadding(new Insets(10, 10, 10, 10));

        Scene scene = new Scene(hBox,  1000, 350);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Головне вікно застосунку виглядатиме так:

До діаграм, похідних від XYChart, можна визначити кілька наборів даних (Series) для візуального порівняння різних залежностей.

Особливу цікавість представляє діаграма LineChart, яка може бути використана для побудови графіків функцій. Для побудови графіку спочатку слід підготувати дані залежності у вигляді набору точок. Точок повинно бути достатньо багато, щоб крива була гладкою, але не надто багато, щоб не уповільнити виконання програми. У наведеній вище програмі здійснюється побудова двох тригонометричних функцій на одному графіку:

package ua.inf.iwanoff.java.advanced.sixth;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.chart.LineChart;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.XYChart;
import javafx.stage.Stage;

import java.util.function.DoubleUnaryOperator;

public class LineChartDemo extends Application {
    @Override
    public void start(Stage primaryStage) {
        primaryStage.setTitle("Тригонометричні функції");
        Scene scene = new Scene(fgGraph(-4, 4, 1, -1.5, 1.5, 1,
                "Синус", Math::sin, "Косинус", Math::cos), 800, 350);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    private LineChart<Number, Number> fgGraph(double xFrom, double xTo, double xStep,
                                              double yFrom, double yTo, double yStep,
                                              String fName, DoubleUnaryOperator f,
                                              String gName, DoubleUnaryOperator g) {
        NumberAxis xAxis = new NumberAxis(xFrom, xTo, xStep);
        NumberAxis yAxis = new NumberAxis(yFrom, yTo, yStep);
        LineChart<Number, Number> graphChart = new LineChart<>(xAxis, yAxis);
        // Вимикаємо "символи" в точках
        graphChart.setCreateSymbols(false);
        double h = (xTo - xFrom) / 100;
        // Додаємо ім'я і точки першої функції:
        XYChart.Series<Number, Number> fSeries = new XYChart.Series<>();
        fSeries.setName(fName);
        for (double x = xFrom; x <= xTo; x += h) {
            fSeries.getData().add(new XYChart.Data<>(x, f.applyAsDouble(x)));
        }
        // Додаємо ім'я і точки другої функції:
        XYChart.Series<Number, Number> gSeries = new XYChart.Series<>();
        gSeries.setName(gName);
        for (double x = xFrom; x <= xTo; x += h) {
            gSeries.getData().add(new XYChart.Data<>(x, g.applyAsDouble(x)));
        }
        // Додаємо обидві функції
        graphChart.getData().addAll(fSeries, gSeries);
        return graphChart;
    }

    public static void main(String[] args) {
        launch(args);
    }
}

3 Приклади програм

3.1 Обчислення значення виразу з рядком

Під час виконання програми користувач вводить вираз, в якому використовується деякий рядок, і отримує результат цього виразу на екрані. Можна використовувати методи класу String. Залежно від мети використання програми можна запропонувати два варіанти її реалізації.

Перший варіант

Перший варіант передбачає, що генерація коду з уведеним користувачем рядком під час виконання програми здійснюватиметься один раз. Припустимо, ми створили проект StringProcessor, пакет ua.inf.iwanoff.java.advanced.sixth і клас StringProcessor. Код може бути таким:

package ua.inf.iwanoff.java.advanced.sixth;

import java.io.*;
import java.lang.reflect.Method;
import java.util.Scanner;
import javax.tools.*;

public class StringProcessor {
    final String sourceFile = "target/classes/ua/inf/iwanoff/java/advanced/sixth/StrFun.java";

    void genSource(String expression) {
        try (PrintWriter out = new PrintWriter(sourceFile)) {
            out.println("package ua.inf.iwanoff.java.advanced.sixth;");
            out.println("public class StrFun {");
            out.println("    public static String transform(String s) {");
            // Додаємо порожній рядок, щоб перетворити на рядок результат будь-якого типу:
            out.println("        return " + expression + " + \"\";");
            out.println("    }");
            out.println("}");
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }

    boolean compile() {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        return compiler.run(null, null, null, sourceFile) == 0;
    }

   public static void main(String[] args) {
        StringProcessor sp = new StringProcessor();
        Scanner scan = new Scanner(System.in);
        System.out.println("Уведіть вираз, який треба виконати над рядком s:");
        String expression = scan.nextLine().replaceAll("\"", "\\\"");
        sp.genSource(expression);
        try {
            if (sp.compile()) {
                System.out.println("Введіть рядок s:");
                String s = scan.nextLine();
                Class<?> cls = Class.forName("ua.inf.iwanoff.java.advanced.sixth.StrFun");
                Method m = cls.getMethod("transform", String.class);
                System.out.println(m.invoke(null, new Object[] { s }));
            }
            else {
                System.out.println("Помилка введення виразу!");
            }
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

}

Другий варіант

Складніший випадок передбачає багаторазове уведення виразу і, відповідно, багаторазову генерацію коду. Проблема полягає в тому, що після завантаження класу його треба видалити (unload), для того, щоб завантажити нову версію з таким же ім'ям. Це нетривіальна задача, яка передбачає створення власного завантажувача. Простіший підхід полягає у створенні класів з різними іменами. Наприклад, до імен класів можна додавати послідовні цілі числа. Тепер програма буде такою:

package ua.inf.iwanoff.java.advanced.sixth;

import java.io.*;
import java.lang.reflect.Method;
import java.util.Scanner;
import javax.tools.*;

public class StringProcessor {
    final String sourceFile = "target/classes/ua/inf/iwanoff/java/advanced/sixth/StrFun";

    void genSource(String expression, int number) {
        try (PrintWriter out = new PrintWriter(sourceFile + number + ".java")) {
            out.println("package ua.inf.iwanoff.java.advanced.sixth;");
            out.println("public class StrFun" + number + "{");
            out.println("    public static String transform(String s) {");
            out.println("        return " + expression + " + \"\";");
            out.println("    }");
            out.println("}");
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }

    boolean compile(int number) {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        return compiler.run(null, null, null, sourceFile + number + ".java") == 0;
    }

     public static void main(String[] args) {
        int number = 0;
        StringProcessor sp = new StringProcessor();
        Scanner scan = new Scanner(System.in);
        String expression;
        do {
            System.out.println("Уведіть вираз, який треба виконати над рядком s "
                    + "(порожній рядок для закінчення):");
            expression = scan.nextLine().replaceAll("\"", "\\\"");
            if (expression.isEmpty()) {
                break;
            }
            sp.genSource(expression, ++number);
            try {
                if (sp.compile(number)) {
                    System.out.println("Введіть рядок s:");
                    String s = scan.nextLine();
                    Class<?> cls = Class.forName("ua.inf.iwanoff.java.advanced.sixth.StrFun" + number);
                    Method m = cls.getMethod("transform", String.class);
                    System.out.println(m.invoke(null, new Object[]{s}));
                }
                else {
                    System.out.println("Помилка введення виразу!");
                }
            }
            catch (Exception e) {
                e.printStackTrace();
            }
        }
        while (!expression.isEmpty());
    }

}

3.2 Робота з контейнером ConcurrentSkipListMap

У наведеній нижче програмі один потік виконання здійснює заповнення ConcurrentSkipListMap парами число / список простих множників, а інший потік знаходить в цьому списку прості числа (числа з одним простим співмножником) і заносить їх до списку. Числа перевіряються в заданому діапазоні.

Клас PrimeFactorization (розкладання на прості множники):

package ua.inf.iwanoff.java.advanced.sixth;

import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.concurrent.ConcurrentSkipListMap;

public class PrimeFactorization implements Runnable {
    static volatile Map<Long, List<Long>> table = new ConcurrentSkipListMap<>();
    private long from, to;

    public long getFrom() {
        return from;
    }

    public long getTo() {
        return to;
    }

    public void setRange(long from, long to) {
        this.from = from;
        this.to = to;
    }

    // Отримання списку дільників заданого числа:
    private List<Long> factors(long n) {
        String number = n + "\t";
        List<Long> result = new ArrayList<>();
        for (long k = 2; k <= n; k++) {
            while (n % k == 0) {
                result.add(k);
                n /= k;
            }
        }
        System.out.println(number + result);
        return result;
    }

    @Override
    public void run() {
        try {
            for (long n = from; n <= to; n++) {
                table.put(n, factors(n));
                Thread.sleep(1);
            }
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    @Override
    public String toString() {
        String result = table.entrySet().size() + " numbers\n";
        for (Map.Entry<?, ?> e : table.entrySet()) {
            result += e + "\n";
        }
        return result;
    }

}

Клас Primes (прості числа):

package ua.inf.iwanoff.java.advanced.sixth;

import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

public class Primes implements Runnable {
    PrimeFactorization pf;
    Set<Long> result;

    public Primes(PrimeFactorization pf) {
        this.pf = pf;
    }

    @Override
    public void run() {
        result = new LinkedHashSet<>();
        try {
            for (long last = pf.getFrom(); last <= pf.getTo(); last++) {
                List<Long> factors;
                do { // намагаємося отримати наступний набір чисел:
                    factors = pf.table.get(last);
                    Thread.sleep(1);
                }
                while (factors == null);
                // Знайдено просте число:
                if (factors.size() == 1) {
                    result.add(last);
                    System.out.println(this);
                }
            }
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    @Override
    public String toString() {
        return " Prime numbers: " + result;
    }
}

Клас PrimesTest:

package ua.inf.iwanoff.java.advanced.sixth;

public class PrimesTest {
    public static void main(String[] args) throws InterruptedException {
        PrimeFactorization pf = new PrimeFactorization();
        Primes primes = new Primes(pf);
        pf.setRange(100000L, 100100L);
        new Thread(pf).start();
        Thread primesThread = new Thread(primes);
        primesThread.start();
        primesThread.join();
        System.out.println(pf);
        System.out.println(primes);
    }
}

3.3 Створення застосунку графічного інтерфейсу користувача для обчислення і відображення простих чисел

Припустимо, необхідно розробити програму отримання простих чисел у діапазоні від 1 до певного значення, яке може бути досить великим. Для знаходження простих чисел будемо застосовувати найпростіший алгоритм послідовної перевірки усіх чисел від 2 до квадратного кореня з числа, що перевіряється. Така перевірка може вимагати багато часу. Для створення зручного інтерфейсу користувача доцільно використовувати окремий потік виконання, у якому здійснюється перевірка. Завдяки цьому можна буде призупиняти та відновлювати пошук, виконувати стандартні маніпуляції з вікном, включаючи його закриття до завершення процесу пошуку.

Кореневою панеллю нашого застосунку буде BorderPane. Сирцевий код JavaFX-застосунку буде таким:

package ua.inf.iwanoff.java.advanced.sixth;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import java.io.IOException;

public class PrimeApp extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage primaryStage) {
        try {
            BorderPane root = (BorderPane)FXMLLoader.load(getClass().getResource("PrimeAppWindow.fxml"));
            Scene scene = new Scene(root, 700, 500);
            primaryStage.setScene(scene);
            primaryStage.setTitle("Прості числа");
            primaryStage.show();
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Інтерфейс користувача включатиме верхню панель (HBox), до якої послідовно додано мітку з текстом "До:", поле введення тексту textFieldTo, а також чотири кнопки buttonStart, buttonSuspend, buttonResume і buttonStop з текстом відповідно "Стартувати", "Призупинити","Продовжити" і "Завершити". На початку виконання програми лише кнопка buttonStart буде доступною. Середню частину займатиме область введення textAreaResults, для якої вимкнене редагування, нижню частину займатиме компонент progressBar, у якому відображатиметься доля виконання процесу пошуку простих чисел. Файл FXML-документа буде таким:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.HBox?>

<?import javafx.scene.control.ProgressBar?>
<BorderPane prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/8.0.121"
            xmlns:fx="http://javafx.com/fxml/1" fx:controller="ua.inf.iwanoff.java.advanced.sixth.PrimeAppController">
    <top>
        <HBox prefHeight="3.0" prefWidth="600.0" spacing="10.0" BorderPane.alignment="CENTER">
            <padding>
                <Insets bottom="10.0" left="10.0" right="10.0" top="10.0"/>
            </padding>
            <Label text="До:"/>
            <TextField fx:id="textFieldTo" text="1000"/>
            <Button fx:id="buttonStart" minWidth="80.0" text="Стартувати" onAction="#startClick"/>
            <Button fx:id="buttonSuspend" minWidth="80.0" disable="true" text="Призупинити" onAction="#suspendClick"/>
            <Button fx:id="buttonResume" minWidth="80.0" disable="true" text="Продовжити" onAction="#resumeClick"/>
            <Button fx:id="buttonStop" minWidth="80.0" disable="true" text="Завершити" onAction="#stopClick"/>
        </HBox>
    </top>
    <center>
        <TextArea fx:id="textAreaResults" editable="false" wrapText="true" BorderPane.alignment="CENTER"/>
    </center>
    <bottom>
        <ProgressBar fx:id="progressBar" maxWidth="Infinity" progress="0"/>
    </bottom>
</BorderPane>

Можна згенерувати порожній клас контролера, додати до нього поля, пов'язані з візуальними компонентами й методи обробки подій:

package ua.inf.iwanoff.java.advanced.sixth;

import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.*;

public class PrimeAppController {
    @FXML private TextField textFieldTo;
    @FXML private Button buttonStart;
    @FXML private Button buttonSuspend;
    @FXML private Button buttonResume;
    @FXML private Button buttonStop;
    @FXML private ProgressBar progressBar;
    @FXML private TextArea textAreaResults;

    @FXML
    private void startClick(ActionEvent actionEvent) {
        
    }

    @FXML
    private void suspendClick(ActionEvent actionEvent) {
        
    }

    @FXML
    private void resumeClick(ActionEvent actionEvent) {
        
    }

    @FXML
    private void stopClick(ActionEvent actionEvent) {
        
    }

}

Після завантаження на виконання вікно програми буде таким:

Тепер окремо від засобів графічного інтерфейсу користувача можна створити клас, який відповідатиме за обчислення простих чисел в окремому потоці. Клас PrimeNumbers реалізуватиме інтерфейс Runnable. Основні "робочі" функції (start(), suspend(), resume() і stop()), які викликатимуться з контролера, змінюють стан об'єкта (поля suspended і stopped). Для забезпечення гнучкості програмі здійснюється оновлення даних про прості числа і відсоток виконання процесу через механізм зворотного виклику – передбачені поля класу типу інтерфейсу Runnable. Для того, щоб виконання методів run() здійснювалося б в іншому потоці, ці функції викликаються за допомогою Platform.runLater(). Сирцевий код класу PrimeNumbers матиме такий вигляд:

package ua.inf.iwanoff.java.advanced.sixth;

import javafx.application.Platform;

public class PrimeNumbers implements Runnable {
    private Thread primesThread;     // нитка обчислення простих чисел
    private int to;                  // кінець діапазону обчислення простих чисел
    private int lastFound;           // останнє знайдене просте число
    private Runnable displayFunc;    // функція, яка викликається для виведення знайденого числа
    private Runnable percentageFunc; // функція, яка оновлює кількість відсотків виконаного процесу
    private Runnable finishFunc;     // функція, яка викликається після закінчення
    private double percentage;
    private boolean suspended;
    private boolean stopped;

    public PrimeNumbers(Runnable addFunc, Runnable percentageFunc, Runnable finishFunc) {
        this.displayFunc = addFunc;
        this.percentageFunc = percentageFunc;
        this.finishFunc = finishFunc;
    }

    public int getTo() {
        return to;
    }

    public void setTo(int to) {
        this.to = to;
    }

    public synchronized int getLastFound() {
        return lastFound;
    }

    private synchronized void setLastFound(int lastFound) {
        this.lastFound = lastFound;
    }

    public synchronized double getPercentage() {
        return percentage;
    }

    private synchronized void setPercentage(double percentage) {
        this.percentage = percentage;
    }

    public synchronized boolean isSuspended() {
        return suspended;
    }

    private synchronized void setSuspended(boolean suspended) {
        this.suspended = suspended;
    }

    public synchronized boolean isStopped() {
        return stopped;
    }

    private synchronized void setStopped(boolean stopped) {
        this.stopped = stopped;
    }

    @Override
    public void run() {
        for (int n = 2; n <= to; n++)
        {
            try {
                setPercentage(n * 1.0 / to);
                // Оновлюємо кількість відсотків:
                if (percentageFunc != null) {
                    Platform.runLater(percentageFunc);
                }
                boolean prime = true;
                for (int i = 2; i * i <= n; i++) {
                    if (n % i == 0) {
                        prime = false;
                        break;
                    }
                }
                Thread.sleep(20);
                if (prime) {
                    setLastFound(n);
                    // Відображаємо знайдене просте число:
                    if (displayFunc != null) {
                        displayFunc.runLater();
                    }
                }
            }
            catch (InterruptedException e) {
                // залежно від стану об'єкта чекаємо на продовження або завершуємо пошук:
                while (isSuspended()) {
                    try {
                        Thread.sleep(100);
                    }
                    catch (InterruptedException e1) {
                        // Перервали у стані очікування:
                        if (isStopped()) {
                            break;
                        }
                    }
                }
                if (isStopped()) {
                    break;
                }
            }
        }
        if (finishFunc != null) {
            Platform.runLater(finishFunc);
        }
    }

    public void start() {
        primesThread = new Thread(this);
        setSuspended(false);
        setStopped(false);
        primesThread.start();
    }

    public void suspend() {
        setSuspended(true);
        primesThread.interrupt();
    }

    public void resume() {
        setSuspended(false);
        primesThread.interrupt();
    }

    public void stop() {
        setStopped(true);
        primesThread.interrupt();
    }
}

Як видно з коду, методи доступу до даних реалізовані з модифікатором synchronized, що унеможливлює одночасний запис і читання недописаних даних з різних потоків. Тому всі звертання до полів класу з його методів здійснюються тільки через функції доступу.

Тепер можна реалізувати код контролера:

package ua.inf.iwanoff.java.advanced.sixth;

import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.*;

public class PrimeAppController {
    @FXML private TextField textFieldTo;
    @FXML private Button buttonStart;
    @FXML private Button buttonSuspend;
    @FXML private Button buttonResume;
    @FXML private Button buttonStop;
    @FXML private ProgressBar progressBar;
    @FXML private TextArea textAreaResults;

    private PrimeNumbers primeNumbers = new PrimeNumbers(this::addToTextArea, this::setProgress, this::finish);

    @FXML
    private void startClick(ActionEvent actionEvent) {
        try {
            primeNumbers.setTo(Integer.parseInt(textFieldTo.getText()));
            textAreaResults.setText("");
            progressBar.setProgress(0);
            buttonStart.setDisable(true);
            buttonSuspend.setDisable(false);
            buttonResume.setDisable(true);
            buttonStop.setDisable(false);
            primeNumbers.start();
        } catch (NumberFormatException e) {
            Alert alert = new Alert(Alert.AlertType.ERROR);
            alert.setTitle("Помилка");
            alert.setHeaderText("Хибний діапазон!");
            alert.showAndWait();

        }
    }

    @FXML
    private void suspendClick(ActionEvent actionEvent) {
        primeNumbers.suspend();
        buttonStart.setDisable(true);
        buttonSuspend.setDisable(true);
        buttonResume.setDisable(false);
        buttonStop.setDisable(false);
    }

    @FXML
    private void resumeClick(ActionEvent actionEvent) {
        primeNumbers.resume();
        buttonStart.setDisable(true);
        buttonSuspend.setDisable(false);
        buttonResume.setDisable(true);
        buttonStop.setDisable(false);
    }

    @FXML
    private void stopClick(ActionEvent actionEvent) {
        primeNumbers.stop();
    }

    private void addToTextArea() {
        textAreaResults.setText(textAreaResults.getText() + primeNumbers.getLastFound() + " ");
    }

    private void setProgress() {
        progressBar.setProgress(primeNumbers.getPercentage());
    }

    private void finish() {
        buttonStart.setDisable(false);
        buttonSuspend.setDisable(true);
        buttonResume.setDisable(true);
        buttonStop.setDisable(true);
    }

}

Методи addToTextArea(), setProgress() і finish() призначені не для безпосереднього виклику з контролера, а для зворотного виклику. Посилання на ці функції передаються в конструктор об'єкта типу PrimeNumbers. Оброблювачі подій викликають відповідні методи класу PrimeNumbers і змінюють доступність кнопок.

4 Вправи для контролю

  1. Створити консольний застосунок, в якому здійснюється демонстрація можливостей синхронізації.
  2. Створити програму графічного інтерфейсу користувача, яка призначена для побудови графіку довільних функцій. Для реалізації використовувати засоби інтерпретації скриптів JavaScript.

5 Контрольні запитання

  1. Як в код на Java включити код на JavaScript?
  2. Як програмно здійснити компіляцію вихідного коду?
  3. Дайте визначення процесу і потоку виконання.
  4. У чому переваги використання потоків виконання?
  5. Які є способи створення потоків виконання в Java?
  6. В яких станах може перебувати потік?
  7. Коли здійснюється припинення і призупинення виконання роботи потоку?
  8. Якими засобами можна здійснити синхронізацію потоків?
  9. У яких випадках робота потоку блокується?
  10. Для чого використовується модифікатор synchronized?
  11. Які контейнерні класи забезпечують безпеку з точки зору потоків?
  12. Для чого використовують контейнер BlockingQueue?

 

up