性能文章>【译】关于Java 并发和多线程编程的几个常见概念>

【译】关于Java 并发和多线程编程的几个常见概念转载

3月前
215922

Java 本身就支持并发编程,在 Java 编程语言和 Java 类库中都有相关并发支持。

但是在编写新代码或与团队进行代码审查时,我们多久考虑一次并发的性能问题呢?一个小错误可是会导致在本地难以重现的生产问题上无休止地调试应用程序。 

这就回到我们为什么需要并发编程的问题了。

在设计并发系统时,我们都不想干扰 CPU 上运行的其他进程,应用程序只要在自己的黑匣子中运行就行。如今,多核 CPU时代,这个问题已经被轻松解决。但是在使用单核的时代,这是如何完成的呢?

在单核 CPU 上,CPU 使用了一种常用的调度技术 Time-Slice。CPU 时间或处理时间被分成称为切片的小块,调度程序负责分配 CPU 时间。该进程将从调度程序获得分配的时间以完成执行作业。

调度程序监听来自进程的状态/事件以移动到下一个进程。这种从一个任务切换到另一个任务的速度非常快。对他来说,用户体验不会受到影响,一切都在同时运行。

当然,有许多算法和技术可用于处理调度。

作为从事并发系统的开发人员,我们如何确保设计出最优的解决方案?

要回答这个问题,我们先来看看线程。 

线程是我们想要以某种方式执行的一堆指令。

在你的代码中,你正在等待打印一个数组,而另一个线程正在从数据库中写入该数组。在这里,根据当时数组中存在的内容,读者将写入一个不完整的数组或为空,这称为竞争条件。 

让我们以单例类为例,尝试看看线程是如何工作的,并尝试研究引擎盖下的竞争条件。

public class SingletonExample {
	private static SingletonExample instance;

	private SingletonExample() {}

	public static SingletonExample getInstance() {
		if(instance == null){
		instance = new SingletonExample();
		}
	return instance;
	}
}

 

CPU时间 

线程1

线程2

Slice1 - 0 ms

检查 SingletonExample 的实例是否为空

等待

Slice2 - 1 ms

此时实例为空,因此进入 if块

等待

Slice3 - 2 ms

线程调度程序暂停 Thread1

处于可运行状态

Slice4 - 3 ms

等待

检查 SingletonExample 的实例是否为空

Slice5 - 4 ms

等待

此时实例为空,因此进入 if 块

Slice6 - 5 ms

等待

创建 SingletonExample 的实例

Slice7 - 6 ms

处于可运行状态

线程调度程序暂停 Thread2

Slice8 - 7 ms

由于它已经被检查为 null,将创建
 一个 SingletonExample 的新实例

 

例如,有 2 个线程 Thread1 和 Thread2,它们都在调用我们 SingletonExample 类的 getInstance()。

上表描述了每个线程在时间片中执行的动作。

在 Slice7 之前,我们没有看到任何问题,并且代码按预期执行。但是,在 slice8 中,调度程序返回到 Thread1 并跳过 null 检查,因为这已经在 slice1 中执行。因此,我们创建了 SingletonExample 的另一个实例,擦除了 Thread2 创建的实例。这是一种竞争条件, 是并发编程中引入的常见错误。

为了解决上面遇到的竞态条件,我们可以使用同步的概念。通过使用同步方法声明,我们可以克服这种竞争条件,让我们看一下示例代码。

public class SingletonExample {
	private static SingletonExample instance;

	private SingletonExample() {}

	public static synchronized SingletonExample getInstance() {
		if(instance == null){
			instance = new SingletonExample();
		}
	return instance;
	}
}

要同步getInstance(),我们需要保护方法不被线程访问。java中的每个对象都有一个用于同步的锁和密钥,如果Thread1尝试使用受保护的代码块,它将请求密钥。锁对象将检查密钥是否可用,如果密钥可用,它将把密钥交给 Thread1 并执行代码。 

现在,Thread2 请求密钥,因为对象没有密钥(密钥由 Thread1 拥有),所以 Thread2 将等待直到它可以使用。这样我们确保 getInstance() 不会进入竞争条件。

现在,关于持有密钥并将其提供给线程的对象,这是由 JVM 完成的。

JVM 使用 SingletonExample 类对象来保存密钥。如果我们在非静态方法上使用 synchronized 关键字,则密钥由我们所在类的实例持有。更好的解决方案是在方法内部创建一个专用的同步块,并将密钥作为参数传递。

public class SingletonExample {

	private final Object lock = new Object();

	public getInstance(){
		synchroznied(lock) {
		// logic goes here
		}
	}
}

这种技术在单一方法上运行良好,但如果我们有多个同步块,我们该如何处理。让我展示一下如果你开始跨多个方法使用同步块可能会出什么问题?

public class DeadlockTest {
	public static Object Lock_Thread1 = new Object();
	public static Object Lock_Thread2 = new Object();

	private static class Thread1 extends Thread {
		public void run() {
			synchronized (Lock_Thread1) {
				System.out.println("Thread 1 - Holding lock_thread1");

				try {
					Thread.sleep(30);
				} catch (InterruptedException e) {
				}
				System.out.println("Thread 1 - Waiting for lock_thread2");
				synchronized (Lock_Thread2) {
					System.out.println("Thread 1 - Holding lock 1 & 2");
				}
			}
		}
	}

	private static class Thread2 extends Thread {
		public void run() {
			synchronized (Lock_Thread2) {
				System.out.println("Thread 2 - Holding lock_thread2");

				try {
					Thread.sleep(30);
				} catch (InterruptedException e) {
				}
				System.out.println("Thread 2 - Waiting for lock_thread1");
				synchronized (Lock_Thread1) {
					System.out.println("Thread 2 - Holding lock 1 & 2");
				}
			}
		}
	}

	public static void main(String args[]) {
		Thread1 T1 = new Thread1();
		Thread2 T2 = new Thread2();
		T1.start();
		T2.start();
	}
}

输出:

Output:
Thread 1 - Holding lock_thread1 
Thread 2 - Holding lock_thread2 
Thread 1 - Waiting for lock_thread2 
Thread 2 - Waiting for lock_thread1

esc to exit...

在上面的例子中,我们可以看到,T1 持有 Lock_Thread1,T2 持有 Lock_Thread2。T2 正在等待锁 lock_thread1,T1 正在等待锁 Lock_Thread2。这种情况称为死锁。两个线程都处于阻塞状态,并且都不会结束,因为每个线程都在等待另一个线程退出。

T1 和 T2 的死锁

 

出现这种情况是因为我们获取锁的顺序。如果我们改变 Lock_Thread1 和 Lock_Thread2 的顺序,然后运行同一个程序,线程将不再永远等待。

public class DeadlockTest {
	public static Object Lock_Thread1 = new Object();
	public static Object Lock_Thread2 = new Object();

	private static class Thread1 extends Thread {
		public void run() {
			synchronized (Lock_Thread1) {
				System.out.println("Thread 1 -  Holding lock_thread1");

				try {
					Thread.sleep(10);
				} catch (InterruptedException e) {
				}
				System.out.println("Thread 1 -  Waiting for lock_thread2");

				synchronized (Lock_Thread2) {
					System.out.println("Thread 1 - Holding lock 1 & 2");
				}
			}
		}
	}

	private static class Thread2 extends Thread {
		public void run() {
          //This is changed to use lock for Thread1
			synchronized (Lock_Thread1) {
				System.out.println("Thread 2 -  Holding lock_thread1");

				try {
					Thread.sleep(10);
				} catch (InterruptedException e) {
				}
				System.out.println("Thread 2 -  Waiting for lock_thread1");

				synchronized (Lock_Thread2) {
					System.out.println("Thread 2 -  Holding lock 1 & 2");
				}
			}
		}
	}

	public static void main(String args[]) {
		Thread1 T1 = new Thread1();
		Thread2 T2 = new Thread2();
		T1.start();
		T2.start();
	}
}

 

Output:
Thread 1 -  Holding lock_thread1 
Thread 1 -  Waiting for lock_thread2 
Thread 1 - Holding lock 1 & 2 
Thread 2 -  Holding lock_thread1 
Thread 2 -  Waiting for lock_thread1 
Thread 2 -  Holding lock 1 & 2

正如我们所见,可以通过确保获取应用程序所需资源的锁定顺序来避免死锁情况。

一个好的设计是使用最少的锁并且如果它已经分配给另一个线程则不共享锁。 

结论

文章简单整理了我们在并发编程中常用的几种概念,希望有点参考作用。

处理并发编程任务时,请注意同步、竞争条件和死锁。

点赞收藏
金色梦想

终身学习。

请先登录,查看2条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

【译】在Java中创建了100万个线程以后

【译】在Java中创建了100万个线程以后

面试官问我接口优化:我一口气说了十八种

面试官问我接口优化:我一口气说了十八种

解读JVM级别本地缓存Caffeine青出于蓝的要诀 —— 缘何会更强、如何去上手

解读JVM级别本地缓存Caffeine青出于蓝的要诀 —— 缘何会更强、如何去上手

品 RocketMQ 源码,学习并发编程三大神器

品 RocketMQ 源码,学习并发编程三大神器

虽然是我遇到的一个棘手的生产问题,但是我写出来之后,就是你的了。

虽然是我遇到的一个棘手的生产问题,但是我写出来之后,就是你的了。

单服务并发出票实践

单服务并发出票实践

2
2