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

Posted 李子捌

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了学习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);
    }
}

时间统计

循环次数串行执行耗时/ms并发执行耗时/ms并发比串行快多少
1万05
10万23
100万34差不多
1000万87差不多
1亿5454差不多
10亿514508差不多

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

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

测试工具:

  • 使用Lmbench3可以测量上下文切换的时长
  • 使用vmstat可以测量上下文切换的次数

vmstat参数的含义:

参数名含义
r表示运行队列(就是说多少个进程真的分配到CPU)
b表示阻塞的进程
swpd虚拟内存已使用的大小,如果大于0,表示你的机器物理内存不足了,如果不是程序内存泄露的原因,那么你该升级内存了或者把耗内存的任务迁移到其他机器。
free空闲的物理内存的大小
buffLinux/Unix系统用来存储,目录里面有什么内容,权限等的缓存
cache用来记忆我们打开的文件,给文件做缓冲
si每秒从磁盘读入虚拟内存的大小,如果这个值大于0,表示物理内存不够用或者内存泄露了,要查找耗内存进程解决掉
so每秒虚拟内存写入磁盘的大小,如果这个值大于0,同上
bi块设备每秒接收的块数量,这里的块设备是指系统上所有的磁盘和其他块设备,默认块大小是1024byte
bo块设备每秒发送的块数量,例如我们读取文件,bo就要大于0。bi和bo一般都要接近0,不然就是IO过于频繁,需要调整
in每秒CPU的中断次数,包括时间中断
cs每秒上下文切换次数
us用户CPU时间
sy系统CPU时间,如果太高,表示系统调用时间长,例如IO操作频繁
wt等待IO CPU时间
# 每隔一秒采集数据,一直采集,直到程序终止
vmstat 1

在这里插入图片描述

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

1.3 如何减少上下文切换

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

  1. 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的id按照Hash算法取模分段,不同的线程处理不同段的数据。
  2. CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
  3. 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程处于等待状态。
  4. 协程。在单线程里实现多任务调度,并在单线程里维持多个任务见的切换。

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,查看当前进程号里的线程在做什么。
# 查看Java进程
jps

结果:
1216
12176 RemoteMavenServer36
18052 ThreadPoolDumpTest
18084 Launcher
15800 Jps

  1. 统计所有线程分别处于什么状态,找出处于(onobjectmonitor)阻塞状态的线程。
# dump下快照
jstack -l 18052 > d:\\dump.txt
  1. 打开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
  1. 此时如果发现是我们在程序中定义的线程池中的线程,则我们应该适当考虑降低线程池的maxThreads的值。

此处示例中我们修改线程池的固定大小为10:

// 创建固定大小的线程池
 ExecutorService fixedThreadPool = Executors.newFixedThreadPool(10);
  1. 修改maxThread值之后我们可以重启项目。再次dump线程信息,然后重新统计(onobjectmonitor)阻塞的线程数。

再次dump快照分析线程运行情况,发现只有10个线程处于WAITING状态了:

"pool-1-thread-10" #21 prio=5 os_prio=0 tid=0x000000001ecde000 nid=0x312c waiting on condition [0x00000000212ef000]
   java.lang.Thread.State: WAITING (parking)

在上面的简单案例中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 避免产生死锁

  1. 避免一个线程同时获取多个锁。
  2. 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
  3. 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
  4. 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

三、资源限制

3.1 什么是资源限制

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

  • 硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU处理速度。
  • 软件资源的限制有数据库的连接和socket连接数等。

3.2 资源限制引发的问题

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

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

  • 对于硬件资源的限制,可以考虑使用集群并行执行程序
  • 对应软件资源的限制,可以考虑使用资源池将资源复用

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

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

本文总结至 – 《Java并发编程的艺术》/《The Art of Java Concurrency Programming》

以上是关于学习Java并发编程之前你不得不知道的那点事的主要内容,如果未能解决你的问题,请参考以下文章

18关于计算机网络你需要知道的那点事

18关于计算机网络你需要知道的那点事

18关于计算机网络你需要知道的那点事

程序员跳槽时关于薪水的那点事

synchronized 与 Lock 的那点事

Python#规范# 关于日志的那点事