JavaRush /Java 博客 /Random-ZH /你不能用线程毁掉 Java:第二部分 - 同步
Viacheslav
第 3 级

你不能用线程毁掉 Java:第二部分 - 同步

已在 Random-ZH 群组中发布

介绍

所以,我们知道 Java 中有线程,您可以在评论“你不能用线程破坏 Java:第一部分 - 线程”中阅读有关线程的内容。需要线程来同时完成工作。因此,线程很可能会以某种方式相互交互。让我们了解这是如何发生的以及我们拥有哪些基本控制措施。 你不能用线程毁掉 Java:第二部分 - 同步 - 1

屈服

Thread.yield()方法很神秘,很少使用。互联网上对其描述有多种变体。以至于有些人写了某种线程队列,其中线程将根据其优先级向下移动。有人写道,线程会将其状态从运行变为可运行(尽管这些状态没有划分,Java也不区分它们)。但实际上,一切都更加未知,而且从某种意义上来说,也更加简单。 你不能用线程毁掉 Java:第二部分 - 同步 - 2关于方法文档的主题,yield有一个错误“ JDK-6416721:(spec thread) Fix Thread.yield() javadoc ”。如果您阅读它,就会清楚地看出,实际上该方法yield只是向 Java 线程调度程序传达一些建议,即可以为该线程分配更少的执行时间。但实际会发生什么、调度程序是否会听到建议以及它通常会做什么取决于 JVM 和操作系统的实现。或者也许是因为其他一些因素。所有的混乱很可能是由于Java语言开发过程中对多线程的重新思考造成的。您可以在评论“ Java Thread.yield()简介”中阅读更多内容。

睡眠 - 入睡主题

线程在执行期间可能会进入睡眠状态。这是与其他线程交互的最简单类型。安装Java虚拟机、执行Java代码的操作系统有自己的线程调度器,称为Thread Scheduler。由他决定何时运行哪个线程。程序员无法直接从 Java 代码与该调度程序交互,但他可以通过 JVM 要求调度程序暂停线程一段时间,以“使其进入睡眠状态”。您可以在文章“ Thread.sleep() ”和“多线程工作原理”中阅读更多内容。此外,您可以了解Windows操作系统中线程是如何工作的:“ Windows线程的内部结构”。现在我们将亲眼所见。让我们将以下代码保存到文件中HelloWorldApp.java
class HelloWorldApp {
    public static void main(String []args) {
        Runnable task = () -> {
            try {
                int secToWait = 1000 * 60;
                Thread.currentThread().sleep(secToWait);
                System.out.println("Waked up");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };
        Thread thread = new Thread(task);
        thread.start();
    }
}
正如您所看到的,我们有一个等待 60 秒的任务,之后程序结束。我们编译javac HelloWorldApp.java并运行java HelloWorldApp。最好在单独的窗口中启动。例如,在 Windows 上,它会是这样的:start java HelloWorldApp。使用 jps 命令,我们找出进程的 PID,并使用以下命令打开线程列表jvisualvm --openpid pidПроцесса你不能用线程毁掉 Java:第二部分 - 同步 - 3如您所见,我们的线程已进入睡眠状态。事实上,休眠当前线程可以做得更漂亮:
try {
	TimeUnit.SECONDS.sleep(60);
	System.out.println("Waked up");
} catch (InterruptedException e) {
	e.printStackTrace();
}
您可能已经注意到我们无处不在进行处理InterruptedException?让我们了解一下原因。

中断线程或 Thread.interrupt

问题是,当线程在梦中等待时,有人可能想中断这个等待。在本例中,我们处理这样的异常。这是在该方法Thread.stop被声明为已弃用之后完成的,即 已经过时且不适合使用。原因是当该方法被调用时,stop线程就被简单地“杀死”了,这是非常不可预测的。我们无法知道流何时会停止,无法保证数据的一致性。想象一下,您正在将数据写入文件,然后流被破坏。因此,我们决定不终止该流,而是通知它应该被中断会更合乎逻辑。如何对此做出反应取决于流程本身。更多详细信息可以在 Oracle 的“ Why is Thread.stop deprecated? ”中找到。让我们看一个例子:
public static void main(String []args) {
	Runnable task = () -> {
		try {
			TimeUnit.SECONDS.sleep(60);
		} catch (InterruptedException e) {
			System.out.println("Interrupted");
		}
	};
	Thread thread = new Thread(task);
	thread.start();
	thread.interrupt();
}
在这个例子中,我们不会等待 60 秒,而是立即打印“Interrupted”。这是因为我们调用了线程的方法interrupt。该方法设置“称为中断状态的内部标志”。也就是说,每个线程都有一个不能直接访问的内部标志。但我们有与此标志交互的本机方法。但这不是唯一的方法。线程可以处于执行过程中,而不是等待某些事情,而只是执行操作。但它可以规定他们希望在工作的某个时刻完成它。例如:
public static void main(String []args) {
	Runnable task = () -> {
		while(!Thread.currentThread().isInterrupted()) {
			//Do some work
		}
		System.out.println("Finished");
	};
	Thread thread = new Thread(task);
	thread.start();
	thread.interrupt();
}
在上面的示例中,您可以看到循环while将一直运行,直到线程被外部中断。关于isInterrupted标志需要了解的重要一点是,如果我们捕获它InterruptedException,该标志isInterrupted将被重置,然后isInterrupted它将返回 false。Thread 类中还有一个仅适用于当前线程的静态方法 - Thread.interrupted(),但该方法将标志重置为 false!您可以在“线程中断”一章中阅读更多内容。

Join — 等待另一个线程完成

最简单的等待类型是等待另一个线程完成。
public static void main(String []args) throws InterruptedException {
	Runnable task = () -> {
		try {
			TimeUnit.SECONDS.sleep(5);
		} catch (InterruptedException e) {
			System.out.println("Interrupted");
		}
	};
	Thread thread = new Thread(task);
	thread.start();
	thread.join();
	System.out.println("Finished");
}
在此示例中,新线程将休眠 5 秒。同时,主线程将等待,直到休眠的线程被唤醒并完成其工作。如果您查看 JVisualVM,线程的状态将如下所示: 你不能用线程毁掉 Java:第二部分 - 同步 - 4借助监视工具,您可以看到线程发生了什么。该方法join非常简单,因为它只是一个带有 java 代码的方法,wait在调用它的线程处于活动状态时执行。一旦线程死亡(终止时),等待就会终止。这就是该方法的全部魔力join。因此,让我们进入最有趣的部分。

概念监视器

在多线程中有一个叫做Monitor的东西。一般来说,“监督者”一词从拉丁语翻译为“监督者”或“监督者”。在本文的框架内,我们将尽力记住本质,对于那些想要的人,我请您深入了解链接中的材料以获取详细信息。让我们从Java语言规范开始我们的旅程,即从JLS开始:“ 17.1.同步”。它是这么说的: 你不能用线程毁掉 Java:第二部分 - 同步 - 5原来,为了线程之间的同步,Java使用了一种叫做“Monitor”的机制。每个对象都有一个与之关联的监视器,线程可以锁定它或解锁它。接下来,我们会在Oracle网站上找到一个培训教程:“ Intrinsic Locks and Synchronization ”。本教程解释了 Java 中的同步是围绕称为内在锁或监视器锁的内部实体构建的。通常这样的锁被简单地称为“监视器”。我们还再次看到,Java 中的每个对象都有一个与其关联的内在锁。你可以阅读《Java——本质锁与同步》。接下来,了解 Java 中的对象如何与监视器关联非常重要。Java 中的每个对象都有一个标头 - 一种内部元数据,程序员无法从代码中获得它,但虚拟机需要它才能正确处理对象。对象标头包含一个 MarkWord,如下所示: 你不能用线程毁掉 Java:第二部分 - 同步 - 6

https://edu.netbeans.org/contrib/slides/java-overview-and-java-se6.pdf

Habr 的一篇文章在这里非常有用:“但是多线程是如何工作的?第一部分:同步。” 在本文中,值得添加 JDK bugtaker 的任务块摘要中的描述:“ JDK-8183909 ”。您可以在“ JEP-8183909 ”中阅读相同的内容。因此,在Java中,监视器与一个对象相关联,并且线程可以阻塞该线程,或者他们也说“获取锁”。最简单的例子:
public class HelloWorld{
    public static void main(String []args){
        Object object = new Object();
        synchronized(object) {
            System.out.println("Hello World");
        }
    }
}
因此,使用关键字,synchronized当前线程(在其中执行这些代码行)尝试使用与该对象关联的监视器object并“获取锁”或“捕获监视器”(第二个选项更好)。如果没有监视器争用(即没有其他人想要在同一个对象上同步),Java 可以尝试执行一种称为“偏向锁定”的优化。Mark Word 中对象的标题将包含相应的标记以及监视器附加到哪个线程的记录。这减少了捕获监视器时的开销。如果监视器之前已经绑定到另一个线程,那么这种锁定是不够的。JVM 切换到下一个锁定类型 - 基本锁定。它使用比较和交换(CAS)操作。同时,Mark Word 中的标头不再存储 Mark Word 本身,而是更改了其存储 + 标记的链接,以便 JVM 知道我们正在使用基本锁定。如果多个线程存在对监视器的争用(一个已捕获监视器,第二个正在等待监视器被释放),则Mark Word中的标记发生变化,并且Mark Word开始存储对监视器的引用为对象 - JVM 的一些内部实体。正如 JEP 中所述,在这种情况下,Native Heap 内存区域需要空间来存储该实体。该内部实体的存储位置的链接将位于Mark Word对象中。因此,正如我们所看到的,监视器实际上是一种确保多个线程对共享资源的访问同步的机制。JVM 在该机制的多种实现之间进行切换。因此,为了简单起见,当谈论监视器时,我们实际上是在谈论锁。 你不能用线程毁掉 Java:第二部分 - 同步 - 7

通过锁同步并等待

正如我们之前所看到的,监视器的概念与“同步块”(或者也称为临界区)的概念密切相关。让我们看一个例子:
public static void main(String[] args) throws InterruptedException {
	Object lock = new Object();

	Runnable task = () -> {
		synchronized (lock) {
			System.out.println("thread");
		}
	};

	Thread th1 = new Thread(task);
	th1.start();
	synchronized (lock) {
		for (int i = 0; i < 8; i++) {
			Thread.currentThread().sleep(1000);
			System.out.print("  " + i);
		}
		System.out.println(" ...");
	}
}
这里,主线程首先将任务发送给新线程,然后立即“捕获”锁并用它执行长时间操作(8秒)。一直以来,任务都无法进入其执行的块synchronized,因为 锁已经被占用。如果线程无法获得锁,它将在监视器处等待。一旦收到,就会继续执行。当线程离开监视器时,它会释放锁。在 JVisualVM 中,它看起来像这样: 你不能用线程毁掉 Java:第二部分 - 同步 - 8正如您所看到的,JVisualVM 中的状态称为“监视器”,因为线程被阻塞并且无法占用监视器。您还可以在代码中找出线程的状态,但该状态的名称与 JVisualVM 术语并不重合,尽管它们很相似。在这种情况下,th1.getState()循环for将返回BLOCKED,因为 当循环运行时,监视器lock被线程占用main,线程th1被阻塞,无法继续工作,直到锁返回。除了同步块之外,还可以同步整个方法。例如,类中的方法HashTable
public synchronized int size() {
	return count;
}
在一个单位时间内,该方法只会被一个线程执行。但我们需要一把锁,对吗?是的,我需要它。对于对象方法,锁将为this。关于这个主题有一个有趣的讨论:“使用同步方法而不是同步块有优势吗? ”。如果该方法是静态的,那么锁将不是this(因为对于静态方法来说它不能是this),而是类对象(例如,Integer.class)。

在监视器上等待、等待。通知和notifyAll方法

线程还有另一个等待方法,它连接到监视器。sleep与and不同join,它不能只是被调用。他的名字是waitwait该方法在我们要等待其监视器的对象上执行。让我们看一个例子:
public static void main(String []args) throws InterruptedException {
	    Object lock = new Object();
	    // task будет ждать, пока его не оповестят через lock
	    Runnable task = () -> {
	        synchronized(lock) {
	            try {
	                lock.wait();
	            } catch(InterruptedException e) {
	                System.out.println("interrupted");
	            }
	        }
	        // После оповещения нас мы будем ждать, пока сможем взять лок
	        System.out.println("thread");
	    };
	    Thread taskThread = new Thread(task);
	    taskThread.start();
        // Ждём и после этого забираем себе лок, оповещаем и отдаём лок
	    Thread.currentThread().sleep(3000);
	    System.out.println("main");
	    synchronized(lock) {
	        lock.notify();
	    }
}
在 JVisualVM 中,它看起来像这样: 你不能用线程毁掉 Java:第二部分 - 同步 - 10要理解它是如何工作的,您应该记住方法wait引用. 与线程相关的方法在. 但这就是答案。我们记得,Java 中的每个对象都有一个标头。标头包含各种服务信息,包括有关监视器的信息 - 有关锁定状态的数据。正如我们所记得的,每个对象(即每个实例)都与称为内在锁(也称为监视器)的内部 JVM 实体相关联。在上面的示例中,任务描述了我们在与 关联的监视器上输入同步块。如果可以获得该监视器的锁定,则. 执行此任务的线程将释放监视器,但将加入等待监视器通知的线程队列。这个线程队列称为WAIT-SET,它更正确地反映了本质。它更像是一组而不是队列。该线程使用任务task创建一个新线程,启动它并等待3秒。这使得新线程很有可能在该线程之前获取锁并在监视器上排队。之后线程本身进入同步块并在监视器上执行线程的通知。发送通知后,线程释放监视器,新线程(之前等待的)等待监视器释放后继续执行。可以仅向其中一个线程发送通知 ( ) 或一次向队列中的所有线程发送通知 ( )。您可以阅读“ Java中notify()和notifyAll()之间的区别”来了解更多内容。值得注意的是,通知顺序取决于 JVM 实现。更多内容可以阅读“如何用notify和notifyall解决饥饿问题? ”。无需指定对象即可执行同步。当同步的不是单独的代码段而是整个方法时,可以完成此操作。例如,对于静态方法,锁将是类对象(通过获取): notifyjava.lang.ObjectObjectlockwaitlocklockmainmainmainlockmainlocklocknotifynotifyAll.class
public static synchronized void printA() {
	System.out.println("A");
}
public static void printB() {
	synchronized(HelloWorld.class) {
		System.out.println("B");
	}
}
在使用锁方面,两种方法是相同的。如果该方法不是静态的,那么将根据当前的 进行同步instance,即根据this。顺便说一句,前面我们说过使用该方法getState可以获取线程的状态。因此,这里是一个由监视器排队的线程,如果该方法wait指定了等待时间限制,则状态将为 WAITING 或 TIMED_WAITING。 你不能用线程毁掉 Java:第二部分 - 同步 - 11

线程的生命周期

正如我们所看到的,心流在生命过程中改变着它的状态。本质上,这些变化就是线程的生命周期。当线程刚刚创建时,它具有 NEW 状态。在这个位置,它还没有启动,Java 线程调度程序还不知道有关新线程的任何信息。为了让线程调度程序了解线程,您必须调用thread.start(). 然后线程将进入RUNNABLE状态。网上有很多不正确的方案,将Runnable和Running状态分开。但这是一个错误,因为... Java 不区分“准备运行”和“正在运行”状态。当线程处于活动状态但不活动(不可运行)时,它处于以下两种状态之一:
  • BLOCKED - 等待进入受保护的部分,即 到synchonized街区。
  • WAITING - 根据条件等待另一个线程。如果条件为真,线程调度程序将启动线程。
如果一个线程正在按时间等待,则它处于 TIMED_WAITING 状态。如果线程不再运行(成功完成或出现异常),它将进入 TERMINATED 状态。要找出线程的状态(它的状态),可以使用该方法getState。线程还有一个方法isAlive,如果线程未终止,则返回 true。

LockSupport 和线程停放

从 Java 1.6 开始,出现了一个有趣的机制,称为LockSupport你不能用线程毁掉 Java:第二部分 - 同步 - 12此类将“许可”或权限与使用它的每个线程相关联。park如果许可证可用,则方法调用立即返回,并在调用期间占用相同的许可证。否则会被阻止。unpark如果许可证尚不可用,则调用该方法可使许可证可用。只有 1 个 Permit。在 Java API 中,LockSupport某个Semaphore. 让我们看一个简单的例子:
import java.util.concurrent.Semaphore;
public class HelloWorldApp{

    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(0);
        try {
            semaphore.acquire();
        } catch (InterruptedException e) {
            // Просим разрешение и ждём, пока не получим его
            e.printStackTrace();
        }
        System.out.println("Hello, World!");
    }
}
这段代码将永远等待,因为信号量现在有 0 个许可。当在代码中调用acquire(即请求权限)时,线程会等待,直到收到权限。既然我们在等待,我们就有义务去处理它InterruptedException。有趣的是,信号量实现了单独的线程状态。如果我们查看 JVisualVM,我们会看到我们的状态不是 Wait,而是 Park。 你不能用线程毁掉 Java:第二部分 - 同步 - 13让我们看另一个例子:
public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            //Запаркуем текущий поток
            System.err.println("Will be Parked");
            LockSupport.park();
            // Как только нас распаркуют - начнём действовать
            System.err.println("Unparked");
        };
        Thread th = new Thread(task);
        th.start();
        Thread.currentThread().sleep(2000);
        System.err.println("Thread state: " + th.getState());

        LockSupport.unpark(th);
        Thread.currentThread().sleep(2000);
}
线程状态将为 WAITING,但 JVisualVM 区分waitfromsynchronizedparkfrom LockSupport。为什么这一点如此重要LockSupport?让我们再次转向 Java API 并查看线程状态 WAITING。如您所见,只有三种方法可以进入。2 种方法 - 这个waitjoin. 第三个是LockSupport。Java 中的锁是基于相同的原理构建的LockSupport,并且代表了更高级别的工具。让我们尝试使用一个。例如,让我们看一下ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class HelloWorld{

    public static void main(String []args) throws InterruptedException {
        Lock lock = new ReentrantLock();
        Runnable task = () -> {
            lock.lock();
            System.out.println("Thread");
            lock.unlock();
        };
        lock.lock();

        Thread th = new Thread(task);
        th.start();
        System.out.println("main");
        Thread.currentThread().sleep(2000);
        lock.unlock();
    }
}
与前面的示例一样,这里一切都很简单。lock等待某人释放资源。如果我们查看 JVisualVM,我们将看到新线程将被停放,直到main该线程给它锁。您可以在此处阅读有关锁的更多信息:“ Java 8 中的多线程编程。第二部分。同步对可变对象的访问”和“ Java Lock API。理论和使用示例”。为了更好地理解锁的实现,阅读概述“ Phaser Class ”中有关 Phazer 的内容很有用。而说到各种同步器,你必须阅读 Habré 上的文章“ Java.util.concurrent.* Synchronizers Reference ”。

全部的

在这篇评论中,我们研究了 Java 中线程交互的主要方式。附加材料: #维亚切斯拉夫
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION