Багатопотоковість у Java. Лекція 6: взаємні блокування та дампи потоків

7 жовтня 2020
Володимир Фролов, Java-розробник, Микита Сізінцев, Android-розробник
Багатопотоковість у Java. Лекція 6: взаємні блокування та дампи потоків
У п'ятій лекції короткого курсу про багатопотоковість йшлося про атомарні змінні та багатопотокові колекції. Цього разу наші колеги розповідають про дампи потоків, прості та приховані дедлоки.

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

6.1 ДАМПИ ПОТОКІВ

Крім дедлоків, буває, що потік протягом дуже тривалого часу чекає на якісь ресурси чи залишається постійно активним, наприклад, виконуючи дуже великий або нескінченний цикл. Для виявлення таких ситуацій та інших проблем, пов'язаних з потоками, JVM надає можливість зробити миттєвий знімок стану потоків. Такий знімок називається thread dump. Це текстовий документ, де перераховано всі потоки, зокрема потоки JVM. Для кожного потоку відображається стандартний набір інформації: ім'я, статус, пріоритет, стек-трейс, є потік демоном чи ні, а також адреса об'єкта блокування, на якому знаходиться потік. Частина такого thread dump наведена в лістингу 1.

Лістинг 1

"Java Thread" #11 prio=5 os_prio=0 tid=0x00007fb0a4356000 nid=0x1242 waiting for monitor entry [0x00007fb078701000] java.lang.Thread.State: BLOCKED (on object monitor)
    at com.da.lect5.deadlock.TwoTasks.lambda$getTask1$0(TwoTasks.java:14)
        - waiting to lock <0x0000000719bf5760> (a java.lang.String)
        - locked <0x0000000719bf5730> (a java.lang.String)
            at com.da.lect5.deadlock.TwoTasks$$Lambda$1/1078694789.run(Unknown   
                Source)
at java.lang.Thread.run(Thread.java:748)

"UNIX Thread" #12 prio=5 os_prio=0 tid=0x00007fb0a4357800 nid=0x1243 waiting for monitor entry [0x00007fb078600000] java.lang.Thread.State: BLOCKED (on object monitor)
    at com.da.lect5.deadlock.TwoTasks.lambda$getTask2$1(TwoTasks.java:27)
        - waiting to lock <0x0000000719bf5730> (a java.lang.String)
        - locked <0x0000000719bf5760> (a java.lang.String)
            at com.da.lect5.deadlock.TwoTasks$$Lambda$2/1747585824.run(Unknown 
                Source)
           at java.lang.Thread.run(Thread.java:748)

"Monitor Ctrl-Break" #5 daemon prio=5 os_prio=0 tid=0x00007fb0a42b5800 nid=0x123b runnable [0x00007fb07901f000] java.lang.Thread.State: RUNNABLE
    at java.net.SocketInputStream.socketRead0(Native Method)
    at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
    at java.net.SocketInputStream.read(SocketInputStream.java:171)
    at java.net.SocketInputStream.read(SocketInputStream.java:141)
    at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
    at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
    at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
    - locked <0x0000000719db96b8> (a java.io.InputStreamReader)
    at java.io.InputStreamReader.read(InputStreamReader.java:184)
    at java.io.BufferedReader.fill(BufferedReader.java:161)
    at java.io.BufferedReader.readLine(BufferedReader.java:324)
    - locked <0x0000000719db96b8> (a java.io.InputStreamReader)
    at java.io.BufferedReader.readLine(BufferedReader.java:389)
    at com.intellij.rt.execution.application.AppMainV2$1.run(AppMainV2.java:64)

Існує декілька способів зняти thread dump:

  • Спочатку дізнатися PID процесу Java-програми. Це можна зробити, викликавши утиліту jps із папки bin, де встановлено JDK. Після цього виконати команду jstack -F <PID>.
  • Використати JVisiualVM.
  • Використати програму Java Mission Control.
  • В IntelliJ Idea звернутися до вікна Run при запущеній програмі.

JavaUNIX

У лістингу 1 можна побачити, що потік Java Thread заблокований на моніторі з адресою 0x0000000719bf5760. Важливо правильно зіставити адресу об'єкта з самим об'єктом, тому що за шістнадцятковим значенням це зробити неможливо. Для цього можна використати код, наведений у лістингу 2.

Лістинг 2

public class AddressUtil {
    private static Unsafe getUnsafeObj() {
        Unsafe unsafe = null;
>        try {
            Field field = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe = (Unsafe) field.get(null);
        } catch (Exception exception) {
            exception.printStackTrace();
        }
        return unsafe;
    }
    private static void printAddresses(String label, Object... objects) {
        Unsafe unsafe = getUnsafeObj();
        if (Objects.nonNull(unsafe)) {
            final boolean is64bit = unsafe.addressSize() == 8;
            System.out.print(label + ": 0x");
            long last = 0;
            int offset = unsafe.arrayBaseOffset(objects.getClass());
            int scale = unsafe.arrayIndexScale(objects.getClass());
            switch (scale) {
                case 4:
                    long factor = is64bit ? 8 : 1;
                    final long i1 = (unsafe.getInt(objects, offset) & 0xFFFFFFFFL) * factor;
                    System.out.print(Long.toHexString(i1));
                    last = i1;
                    for (int i = 1; i < objects.length; i++) {
                        final long i2 = 
                                (unsafe.getInt(objects, offset + i * 4) & 0xFFFFFFFFL) * factor;
                        if (i2 > last) {
                            System.out.print(", +" + Long.toHexString(i2 - last));
                        } else {
                            System.out.print(", -" + Long.toHexString( last - i2));
                        }
                        last = i2;
                    }
            break;
            case 8:
                throw new AssertionError("Not supported");
            }
        }
    }
}

Використовуючи клас у лістингу 2, можна зрозуміти, який потік знаходиться на якому блокуванні. При використанні цього класу слід враховувати, що адреси об'єктів можуть змінюватися після роботи збирача сміття. При аналізі потоків необхідно фільтрувати потоки, які створив користувач, та ті, які запустила сама JVM. Тому зручно призначати імена потокам, як це було показано в лекції номер 2. Рекомендується робити дампи потоків у програмному забезпеченні декілька разів, щоб побачити зміни стану потоків. Якщо одне з ядер процесора завантажене на 100%, слід шукати нескінченний цикл або цикл, що виконується протягом дуже тривалого часу, обробляючи велику кількість даних. Якщо граничного завантаження процесора не спостерігається, але якась робота все одно очікує виконання, значить, виник один з видів дедлоку або потоки чекають на звільнення певного ресурсу.

6.2 ПРОСТЕ ВЗАЄМНЕ БЛОКУВАННЯ

Простий дедлок виникає, коли перший з двох потоків захопив блокування А та намагається захопити блокування B, а другий захопив блокування B та намагається захопити блокування A. Приклад такого дедлоку наведено в лістингу 3.

Лістинг 3

public class DeadLock {
    public static void main(String[] args) {
        TwoTasks tasks = new TwoTasks();
        new Thread(tasks.getTask1(), "Java Thread").start();
        new Thread(tasks.getTask2(), "UNIX Thread").start();
    }
}

public class TwoTasks {
    private String str1 = "Java";
    private String str2 = "UNIX";
    @SuppressWarnings("Duplicates")
    public Runnable getTask1() {
        return () -> {
            while (true) {
                synchronized (str1) {
                    synchronized (str2) {
                        System.out.println(str1 + str2);
                    }
                }
            }
        };
    }

    @SuppressWarnings("Duplicates")
    public Runnable getTask2() {
        return () -> {
            while (true) {
                synchronized (str2) {
                    synchronized (str1) {
                        System.out.println(str2 + str1);
                    }
                }
            }
        };
    }
}

У лістингу 1 створюються два потоки: перший спочатку захоплює блокування на рядку str1, а потім — на str2. Другий потік робить те саме, тільки в іншому порядку. Два потоки намагаються захопити блокування нескінченну кількість разів. Рано чи пізно настане дедлок: коли перший потік захопив блокування на рядку "Java" та хоче захопити блокування на рядку "UNIX". А другий потік уже захопив блокування на рядку "UNIX" та намагається захопити блокування на рядку "Java". У підсумку програма в лістингу 1 перебуватиме у стані взаємного блокування вічно, тобто доки її не зупинять. Рішення в такій ситуації — використовувати один і той самий порядок захоплення та відпускання блокувань у всіх критичних секціях програми.

Не варто використовувати рядки як об'єкти блокування. Це пов'язано з тим, що JVM кешує рядки, оголошені за допомогою літералів. Відповідно, рядки з однаковим змістом посилатимуться на один і той самий об'єкт, хоча можуть бути оголошені в різних частинах програми.

6.3 ПРИХОВАНИЙ ДЕДЛОК

У розділі 6.1 ми розглянули випадок взаємного блокування, який віртуальна Java-машина змогла визначити, що й було показано в thread dump. Однак можуть виникати ситуації, коли Java-машина визначити дедлок не може. Розглянемо таку програму в лістингу 4.

Лістинг 4

public class LockOrderingDeadlockSimulator {
    public static void main(String[] args) {
        CountDownLatch startSignal = new CountDownLatch(1);
        CountDownLatch endSignal = new CountDownLatch(3);

        TasksHolder tasks = new TasksHolder();
        ExecutorService executor = Executors.newFixedThreadPool(3);

        executor.execute(new WorkerThread1(tasks, startSignal, endSignal));
        executor.execute(new WorkerThread2(tasks, startSignal, endSignal));
        Runnable deadlockDetector = 
                new ThreadDeadlockDetector(tasks, startSignal, endSignal);
        executor.execute(deadlockDetector);
        executor.shutdown();

        startSignal.countDown();

        while (!executor.isTerminated()) {
            try {
                endSignal.await();
            } catch (InterruptedException e) {
            }
        }

        System.out.println("LockOrderingDeadlockSimulator done!");
    }
}

public class TasksHolder {
    private final Object SHARED_OBJECT = new Object();
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    public void executeTask1() {
        // 1. Attempt to acquire a ReentrantReadWriteLock READ lock
        lock.readLock().lock();
        // Wait 2 seconds to simulate some work...
        try {
            Thread.sleep(2000);
        } catch (InterruptedException any) {
        }
        try {
            // 2. Attempt to acquire a Flat lock...
            synchronized (SHARED_OBJECT) {
            }
        } finally {
            lock.readLock().unlock();
        }
        System.out.println("executeTask1() :: Work Done!");
    }

    public void executeTask2() {
        // 1. Attempt to acquire a Flat lock
        synchronized (SHARED_OBJECT) {
            // Wait 2 seconds to simulate some work...
            try {
                Thread.sleep(2000);
            } catch (InterruptedException any) {
            }
            // 2. Attempt to acquire a ReentrantReadWriteLock WRITE lock
            lock.writeLock().lock();
            try {
                // Do nothing
            } finally {
                lock.writeLock().unlock();
            }
        }
        System.out.println("executeTask2() :: Work Done!");
    }
    public ReentrantReadWriteLock getReentrantReadWriteLock() {
        return lock;
    }
}

public class WorkerThread1 implements Runnable {
    private final CountDownLatch startSignal;
    private final CountDownLatch endSignal;
    private TasksHolder tasks;
    public WorkerThread1(TasksHolder tasks, CountDownLatch startSignal,
            CountDownLatch endSignal) {
        this.tasks = tasks;
        this.startSignal = startSignal;
        this.endSignal = endSignal;
    }
    @Override
    public void run() {
        try {
            startSignal.await();
            // Execute task #1
            tasks.executeTask1();
        } catch (InterruptedException e) {
        } finally {
            endSignal.countDown();
        }
    }
}

 

public class WorkerThread2 implements Runnable {
    private final CountDownLatch startSignal;
    private final CountDownLatch endSignal;
    private TasksHolder tasks;
    public WorkerThread2(TasksHolder tasks, CountDownLatch startSignal,
            CountDownLatch endSignal) {
        this.tasks = tasks;
        this.startSignal = startSignal;
        this.endSignal = endSignal;
    }
    @Override
    public void run() {
        try {
            startSignal.await();
            
            // Execute task #2
            tasks.executeTask2();
        } catch (InterruptedException e) {
        } finally {
            endSignal.countDown();
        }
    }
}

public class ThreadDeadlockDetector implements Runnable {
    private TasksHolder tasks;
    private final CountDownLatch startSignal;
    private final CountDownLatch endSignal;
    public ThreadDeadlockDetector(TasksHolder tasks, CountDownLatch startSignal,
            CountDownLatch endSignal) {
        this.tasks = tasks;
        this.startSignal = startSignal;
        this.endSignal = endSignal;
    }
    @Override
    public void run() {
        try {
            startSignal.await();
            // Perform 10 iterations with 2 seconds elapsed time
            for (int i = 0; i < 10; i++) {
                // 1. Flat & Reetrant WRITE lock deadlock detection
                ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
                long[] threadIds = threadBean.findDeadlockedThreads();
                int deadlockedThreads = threadIds != null ? threadIds.length : 0;
                System.out.println("\n** Deadlock detectin in progress...");
                System.out.println("Deadlocked threads found by the HotSpot JVM: " +
                        deadlockedThreads);
                // 2. Reetrant READ lock tracking
                System.out.println("READ lock count: " + 
                        tasks.getReentrantReadWriteLock().getReadLockCount());
                Thread.sleep(2000);
            }
        } catch (InterruptedException e) {
        } finally {
            endSignal.countDown();
        }
    }
}

У програмі, наведеній у лістингу 4, створюються три потоки:

  1. У потоці ThreadDeadlockDetector за допомогою MXBean 10 разів з інтервалом у 2 секунди виходить інформація про потоки, що знаходяться у стані дедлоку, ця інформація виводиться на консоль.
  2. У потоці, який виконує task1, спочатку захоплюється блокування на читання, а потім — блокування за допомогою ключового слова synchronized.
  3. У потоці, який виконує task2, спочатку захоплюється блокування з виконанням ключового слова synchronized, а потім — блокування на запис.

Виникає дедлок, але потік ThreadDeadlockDetector його не знаходить. Справа в тому, що readLock влаштований таким чином, що асоціації між потоком і використовуваним ним блокуванням readLock не виникає. Коли потік використовує writeLock чи блокування на підставі ключового слова synchronized, такий зв'язок є. Якщо в лістингу 4 замінити readLock на writeLock, потік для моніторингу виявить взаємне блокування.

6.3 LIVELOCK

Livelock — рекурсивна ситуація, коли два чи більше потоків виконують один і той самий код, і цей код хоче передати управління іншому потоку. Livelock, як і звичайний дедлок, зупинить виконання програми. Ключова різниця — при Livelock потоки не блокуються, вони продовжують споживати процесорний час, хоча виконання програми при цьому заблоковано.

У реальному житті аналогією може служити ситуація, коли одна людина хоче вийти, а інша — увійти у приміщення через одні й ті самі двері. Якщо обидві спробують пропустити одна одну, жодна так і не зможе ані увійти, ані вийти. Схематично Livelock показано на малюнку 1.

Thread LiveLock

Приклад програми — у лістингу 5.

Лістинг 5

public class LiveLock {
    public static void main (String[] args) {
        final Worker worker1 = new Worker("Worker 1 ", true);
        final Worker worker2 = new Worker("Worker 2", true);
        final CommonResource res = new CommonResource(worker1);
        new Thread(() -> worker1.work(res, worker2)).start();
        new Thread(() -> worker2.work(res, worker1)).start();
    }
}

 

public class CommonResource {
    private Worker owner;
    public CommonResource (Worker d) {
        owner = d;
    }
    public Worker getOwner () {
        return owner;
    }
    public synchronized void setOwner (Worker d) {
        owner = d;
    }
}

public class Worker {
    private String name;
    private boolean active;
    private final Object LOCK = new Object();

    public Worker (String name, boolean active) {
        this.name = name;
        this.active = active;
     }

    public String getName () {
        return name;
    }

    public boolean isActive () {
        return active;
    }

    public void work (CommonResource commonResource, Worker otherWorker) {
        synchronized (LOCK) {
            while (active) {
                // wait for the resource to become available.
                if (commonResource.getOwner() != this) {
                    try {
                        LOCK.wait(10);
                    } catch (InterruptedException e) {
                    }
                    continue;
                }
>                // If other worker is also active let it do it's work first
                if (otherWorker.isActive()) {
                    System.out.println(getName() + 
                           " : handover the resource to the worker " + otherWorker.getName());
                    commonResource.setOwner(otherWorker);
                    continue;
                }
>                //now use the commonResource
                System.out.println(getName() + ": working on the common resource");
                active = false;
                commonResource.setOwner(otherWorker);
            }
        }
    }
}

  • Україна, Remote.UA; Україна, Дніпро; Україна, Київ; Україна, Львів; Україна, Одеса; Україна, Харків; Україна, Херсон
    19 листопада
  • Україна, Remote.UA; Україна, Дніпро; Україна, Київ; Україна, Львів; Україна, Одеса; Україна, Харків; Україна, Херсон
    18 листопада
  • Україна, Дніпро; Україна, Харків; Україна, Херсон
    1 листопада
  • Україна, Remote.UA; Україна, Дніпро; Україна, Київ; Україна, Львів; Україна, Одеса; Україна, Харків; Україна, Херсон
    30 жовтня