本文是Java并发编程系列的第一篇,在正式进行Java中的并发编程方式之前,我们先来了解下什么是并发编程。并发编程的优势是什么,又有什么挑战和问题,以及该如何解决?那么首先要搞清楚什么是并发的概念?

Java并发编程之美 java并发编程深度解析_并发编程的艺术

并发的基本概念

并发是指两个或多个事件在同一时间间隔内发生,在多道程序环境下,一段时间内宏观上有多个程序在同时执行,而在同一时刻,单处理器环境下实际上只有一个程序在执行,故微观上这些程序还是在分时的交替进行。操作系统的并发是通过分时得以实现的,和串行以及并行的概念区别:

  • 串行:顺序做不同事的能力:先洗衣服,洗完后做饭。
  • 并发:交替做不同事的能力:一会儿洗衣服,一会儿做饭,交替执行,但快如闪电。洗衣服和做饭的是一个(cpu),在同一个时间段内每个cpu各司其职。并发的实质是一个物理CPU(也可以多个物理CPU) 在若干道程序之间多路复用,并发性是对有限物理资源强制行使多用户共享以提高效率。
  • 并行:同时做不同事的能力:左手洗衣服右手做饭,在同一时刻同时做两件事。并行性指两个或两个以上事件或活动在同一时刻发生。在多道程序环境下,并行性使多个程序同一时刻可在不同CPU上同时执行。

并发关注的是资源充分利用(也就是不让cpu闲下来),并行关注的是一个任务被分解给多个执行者同时做,缩短这个任务的完成时间(也就是尽快做完这件事),操作系统的并发性是指计算机系统中同时存在多个运行着的程序,因此它具有处理和调度多个程序同时执行的能力。在操作系统中,引入进程的目的是使程序能并发执行。并行则是同时间同时刻有几个程序同时运行,有几核就就几个程序在并行。单核CPU只能并发多个程序,多核CPU可以并发也可以并行【4核CPU可以并行4个程序,程序大于核心时就需要用到并发性】

并发编程的挑战

并发编程的优势不言而喻,我们的主要问题是如何解决并发编程带来的挑战,包括线程轮转执行的上下文切换问题、对同步资源加锁时的死锁问题,整体的资源限制问题

上下文切换

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

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换,正是因为有了线程的创建上下文切换的开销,多线程有时候执行起来不一定有单线程快

package com.company;

public class ThreadTest {
    private static final long count = 10000l;
    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
    }
    private static void concurrency() throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread thread = new Thread(() -> {
            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--;
        }
        long time = System.currentTimeMillis() - start;
        thread.join();
        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+",a="+a);
    }
}

当循环执行1万次的时候,打印结果如下:

concurrency :35ms,b=-10000
serial:0ms,b=-10000,a=50000

当循环执行1亿次的时候,打印结果如下:

concurrency :90ms,b=-100000000
serial:65ms,b=-100000000,a=500000000

当循环执行10亿次的时候,打印结果如下:

concurrency :354ms,b=-1000000000
serial:581ms,b=-1000000000,a=705032704

如何减少上下文切换

既然上下文切换会耗费时间资源,那么该如何减少上下文切换呢?减少上下文切换的方法有无锁并发编程CAS算法使用最少线程使用协程

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

关于线程和进程的概念和使用会在后文提到。总而言之就是使用适量的线程尽量少用锁、执行内容分配前置,代码逻辑写漂亮了,上下文切换就少

死锁

我们可以从多线程的设计原则中可以看到,并发编程可以大大提高CPU的利用率,但是因为其存在对共享和可变状态的资源进行访问,所以存在一定的问题。

  • 共享就意味着变量可以被多个线程同时访问。我们知道系统中的资源是有限的,不同的线程对资源都是具有着同等的使用权。有限、公平就意味着竞争,竞争就有可能会引发线程安全问题。
  • 可变是指变量的值在其生命周期内是可以发生改变的。“可变”对应的是“不可变”。我们知道不可变的对象一定是线程安全的,并且永远也不需要额外的同步(因为一个不可变的对象只要构建正确,其外部可见状态永远都不会发生改变)。所以可变意味着存在线程不安全的风险。

二者展现的核心问题就是如何保证共享和可变的资源不被错误的执行,解决方式很简单,就是对于共享的资源让线程协同持有和处理。这里就会用到对资源的锁,而会导致一系列线程同步问题

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

package com.company;

public class ThreadTest {
    public static final  Object moniterA=new Object();
    public static final  Object moniterB=new Object();
    public static void main(String[] args)  {
        Thread t1 = new Thread(()-> {

                synchronized (moniterA) {
                    try {
                        Thread.sleep(2000);    //t1休眠2秒以便t2能拿到moniterB
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (moniterB) {
                        System.out.println("AM");
                    }
                }

        });
        Thread t2 = new Thread(()-> {

            synchronized (moniterB) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (moniterA) {
                    System.out.println("CH");
                }
            }
        });
        t1.start();
        t2.start();
    }

}

查看dump信息可以看到线程的状态:thread-0等待锁

Java并发编程之美 java并发编程深度解析_并发编程的艺术_02


thread-1也在等待锁,这样导致了相互等待锁来执行代码,导致了死锁。

Java并发编程之美 java并发编程深度解析_并发编程_03

如何避免死锁

虽然死锁不能百分百解除,例如t1拿到锁之后,因为一些异常情况没有释放锁(死循环)。又或者是t1拿到一个数据库锁,释放锁的时候抛出了异常,没释放掉,但是有如下几种避免死锁的机制:

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

总而言之就是,如果要使用锁,一个线程只使用一个定时的锁去锁住一个资源

资源限制

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

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

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

  • 对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行
  • 对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用

所以总而言之,解决资源限制的问题依赖于分布式集群的搭建和资源池的建立和复用

总结

本篇Blog主要介绍了什么是并发编程,并发的优势不言而喻,主要是在并发过程中会遇到什么问题(上下文切换、死锁、资源限制),为了解决这些问题,这个系列的Blog才有了存在的意义。