学习Java并发编程之前你不得不知道的那点事

简介: 学习Java并发编程之前你不得不知道的那点事

简介:

并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序最大限度的并发执行。在进行并发编程时,如果希望通过多线程执行任务让程序运行的更快,会面临非常多的挑战,比如上下文切换的问题、死锁问题,以及受限于硬件和软件的资源限制问题,本篇文章介绍几种并发编程的挑战及解决方案,文章总结至《Java并发编程的艺术》


一、上下文切换

即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程执行的时间,因为时间片非常短,所有CPU通过不停的切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一次任务的状态,以便于下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

这就像我们同事读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本英文技术书。这样的切换时会影响读书效率的,同样的道理上下文的切换也会影响多线程的执行速度。


1.1 多线程一定快吗

下面的代码演示串行和并发执行并累加操作的时间,分析并发执行一定比串行执行快么?

package com.lizba.p1;
/**
 * <p>
 *      测试并发执行和串行的速度
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/6/2 23:40
 */
public class ConcurrencyTest {
    /** 执行次数 */
    private static final long count = 10000;
    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
    }
    /**
     * 并发执行
     * @throws InterruptedException
     */
    private static void concurrency() throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread thread = new Thread(new Runnable() {
            public void run() {
                int a = 0;
                for (long i = 0; i < count; i++) {
                    a +=5;
                }
            }
        });
        thread.start();
        int b = 0;
        for (long i = 0; i < count; i++) {
            b--;
        }
        thread.join();
        long time = System.currentTimeMillis() - start;
        System.out.println("concurrency :" + time + "ms, b=" + b);
    }
    /**
     * 串行执行
     */
    private static void serial() {
        long start = System.currentTimeMillis();
        int a = 0;
        for (long i = 0; i < count; i++) {
            a += 5;
        }
        int b = 0;
        for (long i = 0; i < count; i++) {
            b--;
        }
        long time = System.currentTimeMillis() - start;
        System.out.println("serial :" + time + "ms, b=" + b);
    }
}

image.png从上表可以看出,当并发执行累计操作低于百万次时,速度会比串行执行累加操作要慢。为什么在这种情况下并发执行比串行执行要慢呢?这是因为创建线程和上下文切换的时间开销要远远大于简单计算的时间开销。


1.2 测试上下文切换次数和时长

测试工具:


使用Lmbench3可以测量上下文切换的时长

使用vmstat可以测量上下文切换的次数

vmstat参数的含义:

image.pngimage.pngCS(Content Switch)表示上下文切换的次数,从上面的可以看出上下文每秒钟切换1000多次。


1.3 如何减少上下文切换

减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。


无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的id按照Hash算法取模分段,不同的线程处理不同段的数据。

CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。

使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程处于等待状态。

协程。在单线程里实现多任务调度,并在单线程里维持多个任务见的切换。

1.4 减少上下文切换实战

这个例子简单说明如何来减少线程池中大量WAITING线程,来减少上下文切换次数。(本文在Windows环境dump测试)

写一个模拟出现WAITING状态的代码:

package com.lizba.p1;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
 * <p>
 *      线程池Dump测试 -- 代码只是示例
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/6/4 23:26
 */
public class ThreadPoolDumpTest {
    public static void main(String[] args) {
        // 创建固定大小的线程池
        ExecutorService fixedThreadPool = Executors.newFixedThreadPool(300);
        // 初始化线程池中的线程
        for (int i = 0; i < 300; i++) {
            fixedThreadPool.execute(getThread(i));
        }
        while (true) {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("测试!");
        }
    }
    /**
     * 创建线程
     * @param i
     * @return
     */
    private static Runnable getThread(final int i) {
        return new Runnable() {
            public void run() {
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(i);
            }
        };
    }
}
  1. 用jstack命令dump线程信息,可以看当前运行的Java程序的pid,查看当前进程号里的线程在做什么。image.png打开dump文件查看处于(onobjectmonitor)阻塞的线程在做什么。

发现有300个线程处于WAITING状态

"pool-1-thread-300" #311 prio=5 os_prio=0 tid=0x000000002fe46800 nid=0x4880 waiting on condition [0x0000000033cfe000]
   java.lang.Thread.State: WAITING (parking)
  at sun.misc.Unsafe.park(Native Method)
  - parking to wait for  <0x000000077b098178> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
  at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
  at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
  at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
  at java.lang.Thread.run(Thread.java:748)
   Locked ownable synchronizers:
  - None

image.png在上面的简单案例中WAITING线程减少了,系统上下文切换的次数就会减少,因为每一次从WAITING到RUNNABLE都会进行一次上下文的切换。在实际开发中,我们并不会做这么看似低级的操作,但是样例却能给我们代理线程池优化和程序线程优化各方面的解决问题的思路。


二、死锁

锁是一个非常有用的工具,运用的场景非常多,因为它使用起来非常简单,而且易于理解。但同时它会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,就会造成系统功能不可用。


2.1 死锁示例

下面演示一段引起死锁的代码,使得线程t1和线程t2互相等待对方释放锁。

package com.lizba.p1;
/**
 * <p>
 *      死锁示例代码
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/6/5 0:37
 */
public class DeadLockDemo {
    private static final String A = "A";
    private static final String B = "B";
    /**
     * t1\t2互相持有锁
     */
    private void deadLock() {
        Thread t1 = new Thread(new Runnable() {
            public void run() {
                // 持有锁A
                synchronized (A) {
                    try {
                        Thread.currentThread().sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 持有锁B
                    synchronized (B) {
                        System.out.println("hold Lock B");
                    }
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            public void run() {
                // 持有锁B
                synchronized (B) {
                    try {
                        Thread.currentThread().sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 持有锁A
                    synchronized (A) {
                        System.out.println("hold Lock A");
                    }
                }
            }
        });
        t1.start();
        t2.start();
    }
    public static void main(String[] args) {
        new DeadLockDemo().deadLock();
    }
}

这段代码演示的是简单的死锁场景,在现实中大家都不会写出这样的代码。但是,在一些更为复杂的场景中,你可能会遇到这样的问题,比如t1拿到锁之后,因为一些异常情况并没有释放锁(比如死循环)。又或者t1拿到一个数据库锁,释放锁的时候抛出了异常,没有释放掉。

现实中,一旦出现了死锁,业务是可感知的,因为不能继续提供服务了,那么只能通过dump线程查看到底是哪个线程出现了问题,我们分析如下Dump出的线程信息:

"Thread-1" #13 prio=5 os_prio=0 tid=0x000000001e011000 nid=0x5318 waiting for monitor entry [0x000000001fcef000]
   java.lang.Thread.State: BLOCKED (on object monitor)
  at com.lizba.p1.DeadLockDemo$2.run(DeadLockDemo.java:50)
  - waiting to lock <0x000000076b042000> (a java.lang.String)
  - locked <0x000000076b042030> (a java.lang.String)
  at java.lang.Thread.run(Thread.java:748)
   Locked ownable synchronizers:
  - None
"Thread-0" #12 prio=5 os_prio=0 tid=0x000000001e00f800 nid=0x4b38 waiting for monitor entry [0x000000001fbef000]
   java.lang.Thread.State: BLOCKED (on object monitor)
  at com.lizba.p1.DeadLockDemo$1.run(DeadLockDemo.java:33)
  - waiting to lock <0x000000076b042030> (a java.lang.String)
  - locked <0x000000076b042000> (a java.lang.String)
  at java.lang.Thread.run(Thread.java:748)
   Locked ownable synchronizers:
  - None

从上可以看出第33行和第50行引发了死锁。


2.2 避免产生死锁

避免一个线程同时获取多个锁。

避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。

尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。

对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。



三、资源限制

3.1 什么是资源限制

资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或者软件资源。例如,服务器的带宽只有2MB/s,某个资源的下载速度是1MB/s,系统启动10个线程下载资源,下载速度不会变成10MB/s,所以在并发编程时,要考虑这些资源的限制。


硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU处理速度。

软件资源的限制有数据库的连接和socket连接数等。

3.2 资源限制引发的问题

在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这样程序不仅不会加快,反而会更慢,因为增加上下文切换和资源调度的时间。


3.3 如何解决资源限制的问题

对于硬件资源的限制,可以考虑使用集群并行执行程序

对应软件资源的限制,可以考虑使用资源池将资源复用

3.4 在资源限制情况下并发编程

如何在资源限制的情况下,让程序执行的更加快呢?方法就是,根据不同的资源限制调整程序的并发度,比如下载文件程序依赖于两个资源-宽带和硬盘的读写速度。有数据库操作时,涉及数据库连接,如果SQL执行非常快,而线程的数量比数据量连接数大很多,则某些线程会被阻塞,等待数据库连接。



目录
相关文章
|
1月前
|
设计模式 安全 Java
Java编程中的单例模式:理解与实践
【10月更文挑战第31天】在Java的世界里,单例模式是一种优雅的解决方案,它确保一个类只有一个实例,并提供一个全局访问点。本文将深入探讨单例模式的实现方式、使用场景及其优缺点,同时提供代码示例以加深理解。无论你是Java新手还是有经验的开发者,掌握单例模式都将是你技能库中的宝贵财富。
38 2
|
20天前
|
Java 开发者
Java多线程编程中的常见误区与最佳实践####
本文深入剖析了Java多线程编程中开发者常遇到的几个典型误区,如对`start()`与`run()`方法的混淆使用、忽视线程安全问题、错误处理未同步的共享变量等,并针对这些问题提出了具体的解决方案和最佳实践。通过实例代码对比,直观展示了正确与错误的实现方式,旨在帮助读者构建更加健壮、高效的多线程应用程序。 ####
|
26天前
|
JSON Java Apache
非常实用的Http应用框架,杜绝Java Http 接口对接繁琐编程
UniHttp 是一个声明式的 HTTP 接口对接框架,帮助开发者快速对接第三方 HTTP 接口。通过 @HttpApi 注解定义接口,使用 @GetHttpInterface 和 @PostHttpInterface 等注解配置请求方法和参数。支持自定义代理逻辑、全局请求参数、错误处理和连接池配置,提高代码的内聚性和可读性。
102 3
|
28天前
|
安全 Java 编译器
JDK 10中的局部变量类型推断:Java编程的简化与革新
JDK 10引入的局部变量类型推断通过`var`关键字简化了代码编写,提高了可读性。编译器根据初始化表达式自动推断变量类型,减少了冗长的类型声明。虽然带来了诸多优点,但也有一些限制,如只能用于局部变量声明,并需立即初始化。这一特性使Java更接近动态类型语言,增强了灵活性和易用性。
105 53
|
5天前
|
安全 算法 Java
Java多线程编程中的陷阱与最佳实践####
本文探讨了Java多线程编程中常见的陷阱,并介绍了如何通过最佳实践来避免这些问题。我们将从基础概念入手,逐步深入到具体的代码示例,帮助开发者更好地理解和应用多线程技术。无论是初学者还是有经验的开发者,都能从中获得有价值的见解和建议。 ####
|
19天前
|
Java 开发者
Java多线程编程的艺术与实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的技术文档,本文以实战为导向,通过生动的实例和详尽的代码解析,引领读者领略多线程编程的魅力,掌握其在提升应用性能、优化资源利用方面的关键作用。无论你是Java初学者还是有一定经验的开发者,本文都将为你打开多线程编程的新视角。 ####
|
18天前
|
存储 安全 Java
Java多线程编程中的并发容器:深入解析与实战应用####
在本文中,我们将探讨Java多线程编程中的一个核心话题——并发容器。不同于传统单一线程环境下的数据结构,并发容器专为多线程场景设计,确保数据访问的线程安全性和高效性。我们将从基础概念出发,逐步深入到`java.util.concurrent`包下的核心并发容器实现,如`ConcurrentHashMap`、`CopyOnWriteArrayList`以及`BlockingQueue`等,通过实例代码演示其使用方法,并分析它们背后的设计原理与适用场景。无论你是Java并发编程的初学者还是希望深化理解的开发者,本文都将为你提供有价值的见解与实践指导。 --- ####
|
21天前
|
安全 Java 开发者
Java多线程编程中的常见问题与解决方案
本文深入探讨了Java多线程编程中常见的问题,包括线程安全问题、死锁、竞态条件等,并提供了相应的解决策略。文章首先介绍了多线程的基础知识,随后详细分析了每个问题的产生原因和典型场景,最后提出了实用的解决方案,旨在帮助开发者提高多线程程序的稳定性和性能。
|
27天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
24天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin