JavaRush /Java 博客 /Random-ZH /你不能用线程破坏 Java:第三部分 - 交互
Viacheslav
第 3 级

你不能用线程破坏 Java:第三部分 - 交互

已在 Random-ZH 群组中发布
简单概述线程交互的特点。之前,我们了解了线程如何相互同步。这次我们将深入探讨线程交互时可能出现的问题,并讨论如何避免这些问题。我们还将提供一些有用的链接以供更深入的研究。 你不能用线程毁掉 Java:第三部分 - 交互 - 1

介绍

因此,我们知道 Java 中有线程,您可以在评论“线程不能破坏 Java:第一部分 - 线程”中阅读有关线程的内容,并且线程可以彼此同步,我们在评论“中对此进行了处理”线程不能破坏 Java “破坏:第二部分 - 同步”。现在是时候讨论线程如何相互交互了。他们如何共享公共资源?这可能会出现什么问题?

僵局

最严重的问题是死锁。当两个或多个线程永远相互等待时,这称为死锁。我们以Oracle网站上对“死锁”概念的描述为例:
public class Deadlock {
    static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s has bowed to me!%n",
                    this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s has bowed back to me!%n",
                    this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(() -> alphonse.bow(gaston)).start();
        new Thread(() -> gaston.bow(alphonse)).start();
    }
}
这里的死锁可能不会第一次出现,但如果你的程序执行卡住了,就该运行了jvisualvm你不能用线程毁掉 Java:第三部分 - 交互 - 2如果JVisualVM中安装了插件(通过工具->插件),我们可以看到死锁发生在哪里:
"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
    at Deadlock$Friend.bowBack(Deadlock.java:16)
    - waiting to lock &lt33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
线程 1 正在等待线程 0 的锁。为什么会发生这种情况?Thread-1开始执行并执行该方法Friend#bow。它标有关键字synchronized,即我们通过 拿起显示器this。在该方法的入口处,我们收到了另一个Friend. 现在,该线程Thread-1想要在另一个线程上执行一个方法Friend,从而也从他那里获得锁。但是,如果另一个线程(在本例中Thread-0)设法进入该方法bow,则该锁已经繁忙并Thread-1正在等待Thread-0,反之亦然。阻塞是无解的,所以是Dead,也就是死了。既是死亡之握(无法释放)又是无法逃脱的死锁。关于死锁的主题,您可以观看视频:“死锁 - 并发 #1 - 高级 Java ”。

活锁

如果存在死锁,那么是否存在活锁?是的,有)活锁就是线程表面上看起来是活的,但同时它们却不能做任何事情,因为…… 他们试图继续工作的条件无法满足。从本质上讲,活锁类似于死锁,但线程不会“挂”在系统上等待监视器,而是始终在做某事。例如:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class App {
    public static final String ANSI_BLUE = "\u001B[34m";
    public static final String ANSI_PURPLE = "\u001B[35m";

    public static void log(String text) {
        String name = Thread.currentThread().getName(); //like Thread-1 or Thread-0
        String color = ANSI_BLUE;
        int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
        if (val != 0) {
            color = ANSI_PURPLE;
        }
        System.out.println(color + name + ": " + text + color);
        try {
            System.out.println(color + name + ": wait for " + val + " sec" + color);
            Thread.currentThread().sleep(val * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Lock first = new ReentrantLock();
        Lock second = new ReentrantLock();

        Runnable locker = () -> {
            boolean firstLocked = false;
            boolean secondLocked = false;
            try {
                while (!firstLocked || !secondLocked) {
                    firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
                    log("First Locked: " + firstLocked);
                    secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
                    log("Second Locked: " + secondLocked);
                }
                first.unlock();
                second.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        new Thread(locker).start();
        new Thread(locker).start();
    }
}
此代码的成功取决于 Java 线程调度程序启动线程的顺序。如果它先启动Thead-1,我们将得到 Livelock:
Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
从示例中可以看出,两个线程交替尝试捕获两个锁,但都失败了。此外,他们并没有陷入僵局,也就是说,从视觉上看,他们一切都很好,他们正在做自己的工作。 你不能用线程毁掉 Java:第三部分 - 交互 - 3根据 JVisualVM,我们看到睡眠期和停放期(这是当线程尝试占用锁时,它进入停放状态,正如我们之前在谈论线程同步时讨论的那样)。关于活锁这个话题,可以看一个例子:《Java - 线程活锁》。

饥饿

除了阻塞(死锁和活锁)之外,使用多线程时还存在另一个问题 - 饥饿或“饥饿”。这种现象与阻塞的不同之处在于,线程并未被阻塞,但它们只是没有足够的资源供每个人使用。因此,虽然某些线程接管了所有执行时间,但其他线程却无法执行: 你不能用线程毁掉 Java:第三部分 - 交互 - 4

https://www.logicbig.com/

一个超级例子可以在这里找到:“ Java - Thread Starvation and Fairness ”。此示例展示了线程在饥饿状态下如何工作,以及从 Thread.sleep 到 Thread.wait 的一个小更改如何能够均匀分配负载。 你不能用线程毁掉 Java:第三部分 - 交互 - 5

竞赛条件

使用多线程时,存在“竞争条件”之类的情况。这种现象的原因在于,线程之间共享一定的资源,并且代码的编写方式在这种情况下无法提供正确的操作。让我们看一个例子:
public class App {
    public static int value = 0;

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                int oldValue = value;
                int newValue = ++value;
                if (oldValue + 1 != newValue) {
                    throw new IllegalStateException(oldValue + " + 1 = " + newValue);
                }
            }
        };
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
    }
}
此代码第一次可能不会生成错误。它可能看起来像这样:
Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
    at App.lambda$main$0(App.java:13)
    at java.lang.Thread.run(Thread.java:745)
正如您所看到的,在分配时newValue出现了问题,而且newValue还有更多问题。比赛状态中的一些线程设法在这两支球队之间发生变化value。正如我们所看到的,线程之间出现了竞争。现在想象一下,在金钱交易中不犯类似的错误是多么重要……示例和图表也可以在这里找到:“在 Java 线程中模拟竞争条件的代码”。

易挥发的

说到线程的交互,特别值得注意的是关键字volatile。让我们看一个简单的例子:
public class App {
    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Runnable whileFlagFalse = () -> {
            while(!flag) {
            }
            System.out.println("Flag is now TRUE");
        };

        new Thread(whileFlagFalse).start();
        Thread.sleep(1000);
        flag = true;
    }
}
最有趣的是,它很有可能不起作用。新线程不会看到变化flag。要解决此问题,flag您需要为该字段指定一个关键字volatile。如何以及为何?所有操作均由处理器执行。但计算结果需要存储在某个地方。为此,处理器上有主存储器和硬件高速缓存。这些处理器缓存就像一小块内存,访问数据的速度比访问主内存更快。但一切也都有缺点:缓存中的数据可能不是最新的(如上例所示,当标志值未更新时)。因此,该关键字volatile告诉 JVM 我们不想缓存变量。这使您可以看到所有线程中的实际结果。这是一个非常简化的公式。关于这个主题,volatile强烈建议阅读“ JSR 133 (Java Memory Model) FAQ ”的翻译。我还建议您阅读更多有关“ Java Memory Model ”和“ Java Volatile Keyword ”的资料。此外,重要的是要记住,volatile这是关于可见性,而不是关于更改的原子性。如果我们从“Race Condition”中获取代码,我们将在 IntelliJ Idea 中看到提示: 你不能用线程毁掉 Java:第三部分 - 交互 - 6此检查(Inspection)已作为问题IDEA-61117的一部分添加到 IntelliJ Idea 中,该问题已在 2010 年的发行说明中列出。

原子性

原子操作是不可分割的操作。例如,为变量赋值的操作是原子的。不幸的是,增量不是一个原子操作,因为 增量需要多达三个操作:获取旧值、加一、保存该值。为什么原子性很重要?在增量示例中,如果发生竞争条件,则共享资源(即共享值)随时可能突然改变。此外,重要的是 64 位结构也不是原子的,例如longdouble。您可以在这里阅读更多内容:“读写 64 位值时确保原子性”。原子性问题的一个例子可以在下面的例子中看到:
public class App {
    public static int value = 0;
    public static AtomicInteger atomic = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                value++;
                atomic.incrementAndGet();
            }
        };
        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
        Thread.sleep(300);
        System.out.println(value);
        System.out.println(atomic.get());
    }
}
用于使用原子的特殊类Integer将始终向我们显示 30000,但value它会不时发生变化。关于这个主题有一个简短的概述“ Java 中原子变量简介”。Atomic 基于比较和交换算法。您可以在 Habré 的文章“无锁算法的比较 - CAS 和 FAA 使用 JDK 7 和 8 的示例”或维基百科的“与交换的比较”一文中阅读更多相关信息。 你不能用线程毁掉 Java:第三部分 - 交互 - 8

http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html

发生在之前

有一件有趣而神秘的事情——发生在之前。说到流量,它也值得一读。Happens Before 关系指示线程之间的操作的显示顺序。有很多种解释和解释。关于此主题的最新报告之一是此报告:
这段视频最好不要透露任何内容。所以我只会留下视频的链接。您可以阅读《Java - 理解发生在关系之前》。

结果

在这篇评论中,我们研究了线程交互的特性。我们讨论了可能出现的问题以及检测和消除这些问题的方法。有关该主题的附加材料列表: #维亚切斯拉夫
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION