线程与并发介绍

本文是我们学院课程中名为Java Concurrency Essentials的一部分

在本课程中,您将深入探讨并发的魔力。 将向您介绍并发和并发代码的基础知识,并学习诸如原子性,同步和线程安全之类的概念。 在这里查看

1.有关线程的基本知识

并发是程序同时执行多个计算的能力。 这可以通过在计算机的可用CPU内核上甚至在同一网络内的不同计算机上分布计算来实现。

为了更好地理解并行执行,我们必须区分进程和线程。 进程是由操作系统提供的执行环境,它具有自己的一组私有资源(例如,内存,打开的文件等)。 相反, Threads是指生活在一个流程中并与该流程的其他线程共享资源(内存,打开的文件等)的流程。

在不同线程之间共享资源的能力使线程更适合于对性能有重要要求的任务。 尽管可以在同一计算机上甚至在同一网络内的不同计算机上运行的不同进程之间建立进程间通信,但是出于性能原因,通常会选择线程来并行化单台计算机上的计算。

在Java中,进程对应于正在运行的Java虚拟机(JVM),而线程位于同一个JVM中,并且可以由Java应用程序在运行时动态创建和停止。 每个程序至少有一个线程:主线程。 这个主线程是在每个Java应用程序启动期间创建的,并且它是调用程序的main()方法的那个线程。 从这一点开始,Java应用程序可以创建新的线程并使用它们。

下面的源代码对此进行了演示。 通过JDK类java.lang.Thread的静态方法currentThread()提供对当前Thread访问:

public class MainThread {
		
		public static void main(String[] args) {
			long id = Thread.currentThread().getId();
			String name = Thread.currentThread().getName();
			int priority = Thread.currentThread().getPriority();
			State state = Thread.currentThread().getState();
			String threadGroupName = Thread.currentThread().getThreadGroup().getName();
			System.out.println("id="+id+"; name="+name+"; priority="+priority+"; state="+state+"; threadGroupName="+threadGroupName);
		}
	}

从这个简单应用程序的源代码中可以看到,我们直接在main()方法中访问当前Thread ,并打印出有关它的一些信息:

id=1; name=main; priority=5; state=RUNNABLE; threadGroupName=main

输出揭示了有关每个线程的一些有趣信息。 每个线程都有一个标识符,该标识符在JVM中是唯一的。 线程的名称有助于在监视运行中的JVM的外部应用程序(例如调试器或JConsole工具)中找到某些线程。 当执行多个线程时,优先级决定下一个应该执行的任务。

关于线程的真相是,并非所有线程都真正同时执行,而是将每个CPU内核上的执行时间划分为小片,并将下一个时间片分配给具有最高优先级的下一个等待线程。 JVM的调度程序根据线程的优先级确定下一个要执行的线程。

在优先级旁边,线程还具有状态,可以是以下状态之一:

  • 新:尚未启动的线程处于此状态。
  • 可运行:在Java虚拟机中执行的线程处于此状态。
  • BLOCKED:一个等待监视器锁定的被阻塞线程处于此状态。
  • 等待:无限期等待另一个线程执行特定操作的线程处于此状态。
  • TIMED_WAITING:正在等待另一个线程执行操作的线程最多达到指定的等待时间,该线程处于此状态。
  • 终止:退出的线程处于此状态。

上面示例中的主线程当然处于RUNNABLE状态。 像BLOCKED这样的状态名称已经在这里表明线程管理是高级主题。 如果处理不正确,线程可能会相互阻塞,进而导致应用程序挂起。 但是我们稍后会谈到。

最后但并非最不重要的threadGroup是,线程的属性threadGroup指示线程是按组管理的。 每个线程都属于一组线程。 JDK类java.lang.ThreadGroup提供了一些方法来处理整个Threads组。 通过这些方法,我们可以例如中断组中的所有线程或设置其最大优先级。

2.创建和启动线程

现在,我们已经仔细研究了线程的属性,是时候创建和启动我们的第一个线程了。 基本上,有两种方法可以用Java创建线程。 第一个是编写一个扩展JDK类java.lang.Thread类:

public class MyThread extends Thread {
		
		public MyThread(String name) {
			super(name);
		}

		@Override
		public void run() {
			System.out.println("Executing thread "+Thread.currentThread().getName());
		}
		
		public static void main(String[] args) throws InterruptedException {
			MyThread myThread = new MyThread("myThread");
			myThread.start();
		}
	}

从上面可以看到,类MyThread扩展了Thread类并覆盖了run()方法。 虚拟机启动线程后,将执行run()方法。 由于虚拟机必须做一些工作才能设置线程的执行环境,因此我们无法直接调用此方法来启动线程。 相反,我们在类MyThread的实例上调用方法start() 。 当此类从其超类继承方法stop() ,该方法背后的代码告诉JVM为线程分配所有必需的资源并启动该线程。 当我们运行上面的代码时,我们看到输出“ Executing thread myThread”。 与我们的介绍示例相反,方法run()的代码不是在“主”线程中执行的,而是在我们自己的名为“ myThread”的线程中执行的。

创建线程的第二种方法是实现接口Runnable

public class MyRunnable implements Runnable {

		public void run() {
			System.out.println("Executing thread "+Thread.currentThread().getName());
		}
		
		public static void main(String[] args) throws InterruptedException {
			Thread myThread = new Thread(new MyRunnable(), "myRunnable");
			myThread.start();
		}
	}

与子类化方法的主要区别在于,我们创建了java.lang.Thread的实例,并提供了将Runnable接口实现为Thread构造函数的参数的类的实例。 在此实例旁边,我们还传递了Thread的名称,以便从命令行执行程序时看到以下输出:“ Executing thread myRunnable”。

是否应该使用子类化或接口方法,取决于您的喜好。 该接口是一种更轻便的方法,因为您要做的就是实现接口。 该类仍然可以是某些其他类的子类。 您还可以将自己的参数传递给构造函数,而Thread子类将您限制为Thread类带来的可用构造函数。

在本系列的稍后部分,我们将了解线程池,并了解如何启动多个相同类型的线程。 在这里,我们将再次使用Runnable方法。

3.睡觉和打断

一旦启动了Thread ,它将一直运行直到run()方法结束。 在上面的示例中, run()方法所做的只是打印出当前线程的名称。 因此线程很快完成。

在现实世界的应用程序中,通常必须实现某种类型的后台处理,在这种处理中,线程必须运行,直到例如已经处理了目录结构中的所有文件。 另一个常见的用例是有一个后台线程,它在发生任何事情(例如,已创建文件)时每隔n秒查看一次,并启动某种操作。 在这种情况下,您将必须等待n秒或毫秒。 您可以使用while循环来实现这一点,该循环的主体获取当前的毫秒数并查看下一秒的时间。 尽管这样的实现可行,但是由于您的线程占用了CPU并一次又一次地获取当前时间,因此浪费了CPU处理时间。

对于此类用例,一种更好的方法是调用java.lang.Thread类的sleep()方法,如以下示例所示:

public void run() {
		while(true) {
			doSomethingUseful();
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}

调用sleep()会使当前Thread进入睡眠状态,而不会占用任何处理时间。 这意味着当前线程将从活动线程列表中删除自身,并且调度程序不会在第二次(以毫秒为单位)过去之前将其调度为下一次执行。

请注意,传递给sleep()方法的时间只是调度程序的指示,而不是绝对准确的时间范围。 由于实际的调度,线程可能会提前几纳秒或几毫秒返回。 因此,您不应将此方法用于实时调度。 但是对于大多数使用情况,所达到的精度是足够的。

在上面的代码示例中,您可能已经注意到sleep()可能抛出的InterruptedException 。 中断是线程交互的一个非常基本的功能,可以理解为一个线程发送到另一个线程的简单中断消息。 接收线程可以通过调用Thread.interrupted()方法显式地询问它是否已被中断,或者在将其时间花在诸如sleep()之类的方法上时会隐式中断,该方法在发生中断的情况下会引发异常。

让我们用下面的代码示例仔细看一下中断:

public class InterruptExample implements Runnable {

		public void run() {
			try {
				Thread.sleep(Long.MAX_VALUE);
			} catch (InterruptedException e) {
				System.out.println("["+Thread.currentThread().getName()+"] Interrupted by exception!");
			}
			while(!Thread.interrupted()) {
				// do nothing here
			}
			System.out.println("["+Thread.currentThread().getName()+"] Interrupted for the second time.");
		}

		public static void main(String[] args) throws InterruptedException {
			Thread myThread = new Thread(new InterruptExample(), "myThread");
			myThread.start();
			
			System.out.println("["+Thread.currentThread().getName()+"] Sleeping in main thread for 5s...");
			Thread.sleep(5000);
			
			System.out.println("["+Thread.currentThread().getName()+"] Interrupting myThread");
			myThread.interrupt();
			
			System.out.println("["+Thread.currentThread().getName()+"] Sleeping in main thread for 5s...");
			Thread.sleep(5000);
			
			System.out.println("["+Thread.currentThread().getName()+"] Interrupting myThread");
			myThread.interrupt();
		}
	}

在main方法中,我们首先启动一个新线程,如果不中断它将会休眠很长时间(大约290.000年)。 为了在这段时间之前完成程序,通过在main方法中对其实例变量调用interrupt()来中断myThread 。 这会在sleep()调用中导致InterruptedException ,并在控制台上显示为“ Interrupted by exception!”。 记录了异常后,线程会进行一些繁忙的等待,直到设置了线程上的中断标志为止。 通过在线程的实例变量上调用interrupt()再次从主线程进行设置。 总的来说,我们在控制台上看到以下输出:

[main] Sleeping in main thread for 5s...
[main] Interrupting myThread
[main] Sleeping in main thread for 5s...
[myThread] Interrupted by exception!
[main] Interrupting myThread
[myThread] Interrupted for the second time.

此输出中有趣的是第3行和第4行。如果我们遍历代码,我们可能期望字符串“ Interrupted by exception!”。 在主线程再次开始休眠之前,将打印出“休眠5s…”。 但是从输出中可以看到,调度程序在再次启动myThread之前已经执行了主线程。 因此,在主线程开始休眠之后,myThread打印出接收到的异常。

当使用多个线程进行编程时,这是一个基本观察结果,即很难预测线程的日志记录输出,因为很难计算下一个要执行的线程。 当您不得不处理更多的线程(如上例所示)的暂停没有被硬编码时,情况变得更加糟糕。 在这些情况下,整个程序会获得某种内部动态,这使得并发编程成为一项艰巨的任务。

4.连接线程

正如在上一节中所看到的,我们可以让我们的线程进入睡眠状态,直到被另一个线程唤醒。 您将不时使用的线程的另一个重要功能是线程等待另一个线程终止的能力。

假设您必须实施某种数字运算,可以将其分为几个并行运行的线程。 启动所谓的工作线程的主线程必须等待,直到其所有子线程都终止。 以下代码显示了如何实现此目的:

public class JoinExample implements Runnable {
		private Random rand = new Random(System.currentTimeMillis());

		public void run() {
			//simulate some CPU expensive task
			for(int i=0; i<100000000; i++) {
				rand.nextInt();
			}
			System.out.println("["+Thread.currentThread().getName()+"] finished.");
		}

		public static void main(String[] args) throws InterruptedException {
			Thread[] threads = new Thread[5];
			for(int i=0; i<threads.length; i++) {
				threads[i] = new Thread(new JoinExample(), "joinThread-"+i);
				threads[i].start();
			}
			for(int i=0; i<threads.length; i++) {
				threads[i].join();
			}
			System.out.println("["+Thread.currentThread().getName()+"] All threads have finished.");
		}
	}

在我们的main方法中,我们创建了一个由5个Threads的数组,它们全部一个接一个地启动。 一旦启动它们,我们就在主Thread等待它们的终止。 线程本身通过计算一个随机数来模拟一些数字运算。 完成后,将打印“完成”。 最后,主线程确认其所有子线程的终止:

[joinThread-4] finished.
[joinThread-3] finished.
[joinThread-2] finished.
[joinThread-1] finished.
[joinThread-0] finished.
[main] All threads have finished.

您将观察到,“完成”消息的顺序因执行而异。 如果您多次执行该程序,您可能会看到最先完成的线程并不总是相同的。 但是最后一条语句始终是等待其子级的主线程。

5.同步

正如我们在最后一个示例中所看到的,执行所有正在运行的线程的确切顺序取决于线程配置,例如优先级还取决于可用的CPU资源以及调度程序选择下一个线程执行的方式。 尽管调度程序的行为是完全确定性的,但是很难预测在给定时间点哪个线程在哪个时刻执行。 这使得对共享资源的访问变得至关重要,因为很难预测哪个线程将是尝试访问它的第一个线程。 通常,对共享资源的访问是排他的,这意味着在给定时间点只有一个线程应访问该资源,而没有任何其他线程干扰此访问。

一个并发访问独占资源的简单示例是一个静态变量,该变量增加一个以上线程:

public class NotSynchronizedCounter implements Runnable {
		private static int counter = 0;

		public void run() {
			while(counter < 10) {
				System.out.println("["+Thread.currentThread().getName()+"] before: "+counter);
				counter++;
				System.out.println("["+Thread.currentThread().getName()+"] after: "+counter);
			}
		}

		public static void main(String[] args) throws InterruptedException {
			Thread[] threads = new Thread[5];
			for(int i=0; i<threads.length; i++) {
				threads[i] = new Thread(new NotSynchronizedCounter(), "thread-"+i);
				threads[i].start();
			}
			for(int i=0; i<threads.length; i++) {
				threads[i].join();
			}
		}
	}

当我们仔细查看此简单应用程序的输出时,我们看到类似以下内容的内容:

[thread-2] before: 8
[thread-2] after: 9
[thread-1] before: 0
[thread-1] after: 10
[thread-2] before: 9
[thread-2] after: 11

在这里,线程2将当前值检索为8,然后将其递增,然后是9。这就是我们之前期望的。 但是以下线程执行的内容可能使我们感到惊讶。 线程1将当前值输出为零,将其递增,然后值是10。这怎么发生? 当线程1读取变量计数器的值时,该值为0。然后上下文切换执行了第二个线程,并且当线程1再次轮到该线程时,其他线程已经将计数器递增到9。结果是10。

此类问题的解决方案是Java中的同步关键字。 使用同步,您可以创建只能由线程访问的语句块,该线程获得了对同步资源的锁定。 让我们从上一个示例中更改run()方法,并为整个类引入一个同步块:

public void run() {
		while (counter < 10) {
			synchronized (SynchronizedCounter.class) {
				System.out.println("[" + Thread.currentThread().getName() + "] before: " + counter);
				counter++;
				System.out.println("[" + Thread.currentThread().getName() + "] after: " + counter);
			}
		}
	}

synchronized(SynchronizedCounter.class)语句就像一个屏障,在该屏障中,所有线程都必须停止并要求进入。 只有第一个获得资源锁的线程才被允许通过。 一旦离开了同步块,便可以进入另一个等待线程,依此类推。

通过围绕输出的同步块,并在输出上方增加计数器的增量,如下例所示:

[thread-1] before: 11
[thread-1] after: 12
[thread-4] before: 12
[thread-4] after: 13

现在,您将只看到计数器变量加1之前和之后的后续输出。
可以以两种不同的方式使用synced关键字。 可以在上述方法中使用它。 在这种情况下,您必须提供一个被当前线程锁定的资源。 必须谨慎选择该资源,因为基于变量的范围,线程屏障变得完全不同。

如果变量是当前类的成员,则所有线程都将与该类的实例同步,因为每个LocalSync实例都存在变量sync:

public class LocalSync {
		private Integer sync = 0;

		public void someMethod() {
			synchronized (sync) {
				// synchronized on instance level
			}
		}
	}

除了创建覆盖整个方法主体的块之外,您还可以添加与方法签名同步的关键字。 下面的代码与上面的代码具有相同的作用:

public class MethodSync {
		private Integer sync = 0;

		public synchronized void someMethod() {
			// synchronized on instance level
		}
	}

两种方法之间的主要区别在于,第一种方法的粒度更细,因为您可以使同步块比方法主体小。 请记住,同步块一次只能由一个线程执行,因此每个同步块都是潜在的性能问题,因为所有并发运行的线程可能必须等待直到当前线程离开该块。 因此,我们应始终尝试使块尽可能小。

大多数情况下,您将不得不同步对每个JVM仅存在一次的某些资源的访问。 常用的方法是使用类的静态成员变量:

public class StaticSync {
		private static Integer sync = 0;

		public void someMethod() {
			synchronized (sync) {
				// synchronized on ClassLoader/JVM level
			}
		}
	}

上面的代码同步在同一JVM中通过方法someMethod()运行的所有线程,因为静态变量在同一JVM中仅存在一次。 您可能知道,如果一个类由同一类加载器加载,则该类仅在一个JVM中是唯一的。 如果使用多个类加载器加载类StaticSync ,则静态变量将不止一次存在。 但是在大多数日常应用程序中,您不会有多个类加载器来加载同一类两次,因此您可以假定静态变量仅存在一次,因此同一JVM中的所有线程都必须等待障碍,直到它们获得锁。

6.原子访问

在上一节中,我们看到了当许多并发线程必须执行代码的特定部分但每个时间点只有一个线程应该执行它时,如何同步对某些复杂资源的访问。 我们还看到,如果不同步对公共资源的访问,则对这些资源的操作会交织并可能导致非法状态。

Java语言提供了一些基本的原子操作,因此可用于确保并发线程始终看到相同的值:

  • 对参考变量和原始变量(长整型和双精度型除外)的读写操作
  • 对声明为易失性的所有变量的读写操作

为了更详细地了解这一点,我们假设我们有一个HashMap填充了从文件中读取的属性,以及一堆使用这些属性的线程。 显然,这里我们需要某种同步,因为读取文件和更新Map花费时间,并且在此期间将执行其他线程。

我们无法在所有线程之间轻松共享此Map一个实例,并且无法在更新过程中使用此Map 。 这将导致Map状态不一致,该状态由访问线程读取。 有了上一节的知识,我们当然可以在映射的每次访问(读/写)周围使用一个同步块,以确保所有线程仅看到一个状态,而不是部分更新的Map 。 但是,如果必须非常频繁地从Map读取并发线程,则会导致性能问题。

为同步块中的每个线程克隆Map并让每个线程在单独的副本上工作也是一种解决方案。 但是每个线程都必须不时请求更新的副本,并且该副本占用内存,这在每种情况下都不可行。 但是有一个更简单的解决方案。

由于我们知道对引用的写操作是原子的,因此每次读取文件并在一个原子操作中更新线程之间共享的引用时,我们都可以创建一个新的Map 。 在此实现中,工作线程将永远不会读取不一致的Map因为使用一个原子操作更新了Map

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class AtomicAssignment implements Runnable {
	private static volatile Map<String, String> configuration = new HashMap<String, String>();

	public void run() {
		for (int i = 0; i < 10000; i++) {
			Map<String, String> currConfig = configuration;
			String value1 = currConfig.get("key-1");
			String value2 = currConfig.get("key-2");
			String value3 = currConfig.get("key-3");
			if (!(value1.equals(value2) && value2.equals(value3))) {
				throw new IllegalStateException("Values are not equal.");
			}
			try {
				Thread.sleep(10);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}

	public static void readConfig() {
		Map<String, String> newConfig = new HashMap<String, String>();
		Date now = new Date();
		SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss:SSS");
		newConfig.put("key-1", sdf.format(now));
		newConfig.put("key-2", sdf.format(now));
		newConfig.put("key-3", sdf.format(now));
		configuration = newConfig;
	}

	public static void main(String[] args) throws InterruptedException {
		readConfig();
		Thread configThread = new Thread(new Runnable() {
			public void run() {
				for (int i = 0; i < 10000; i++) {
					readConfig();
					try {
						Thread.sleep(10);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			}
		}, "configuration-thread");
		configThread.start();
		Thread[] threads = new Thread[5];
		for (int i = 0; i < threads.length; i++) {
			threads[i] = new Thread(new AtomicAssignment(), "thread-" + i);
			threads[i].start();
		}
		for (int i = 0; i < threads.length; i++) {
			threads[i].join();
		}
		configThread.join();
		System.out.println("[" + Thread.currentThread().getName() + "] All threads have finished.");
	}
}

上面的示例稍微复杂一点,但并不难理解。 共享的MapAtomicAssignment的配置变量。 在main()方法中,我们最初读取配置一次,然后向Map添加三个具有相同值的键(此处为当前时间,包括毫秒)。 然后,我们启动一个“配置线程”,该线程通过将当前时间戳始终添加到地图的三倍来模拟配置的读取。 然后,五个工作线程使用配置变量读取Map并比较三个值。 如果它们不相等,则抛出IllegalStateException。

您可以运行该程序一段时间,并且不会看到任何IllegalStateException 。 这是由于以下事实:我们通过一次原子操作将新Map分配给共享配置变量:

configuration = newConfig;

我们还可以在一个原子步骤中读取共享变量的值:

Map<String, String> currConfig = configuration;

由于这两个步骤都是原子的,因此我们将始终引用所有三个值相等的有效Map实例。 例如,如果以直接使用配置变量而不是先将其复制到本地变量的方式更改run()方法,则很快就会看到IllegalStateExceptions因为配置变量始终指向“当前”配置。 当配置线程更改了它之后,对Map后续读取访问将已经读取新值,并将它们与旧Map中的值进行比较。

如果直接在配置变量上使用readConfig()方法而不是创建新的Map并通过一次原子操作将其分配给共享变量,则情况也是如此。 但是可能要花一些时间,直到看到第一个IllegalStateException为止。 这对于使用多线程的所有应用程序都是如此。 并发问题乍一看并不总是显而易见的,但是它们需要在重负载条件下进行一些测试才能出现。

翻译自: https://www.javacodegeeks.com/2015/09/introduction-to-threads-and-concurrency.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值