多线程

多线程程序扩展了多任务处理的概念,但是它的层级更低:即一个独立的程序看起来像是在同一时间执行多个任务,每个任务在一个独立的线程中运行

多线程和多进程的区别,核心区别是每个进程拥有完全的变量集,而线程共享相同的数据(这里应该是简化概念了)。共享变量有一些危险(后续详解)。但是它让线程之间的通信变得高效,并且在编程上比写进程内通信要简单

在一些操作系统中,线程是”轻量化的“,创建和销毁一个线程的开销比创建进程要低得多

这里只包含线程的基本知识~

什么是线程

创建并且开启一个线程:

  1. 写一个实现了Runnable接口的类,实现run方法(Runnable是一个函数式接口,可以使用lambda表达式)
  2. 创建一个Thread对象 Thread t = new Thread(r);
  3. t.start()

一般来说,中断(interruption)是用来请求使线程终止(terminate),且run方法会退出(InterruptedException被抛出)

还可以通过继承Thread类来实现一个线程(run方法)(这种方式不再推荐,运行的任务应该和运行过程解耦--即线程执行和逻辑不应该写在一个地方,继承方式其实就是一个任务一个线程的方式。这种情况下如果任务非常多,那么创建线程的开销非常大--可以采用线程池)

run()方法实在同一个线程中执行,start()方法会开启一个线程并运行run()方法

Arrays.fill(T[], T) 用T值填满给定的T类型数组

线程状态

  • New--新建
  • Runnable--可运行的
  • Blocked--阻塞
  • Waiting--等待
  • Timeed Waiting--超时等待?
  • Terminated--终止

使用getState()方法来获取当前线程状态

New Threads

在使用new创建了一个新线程对象,且还没有start(),此时它处于新建状态。在线程运行之前还要做一些额外工作

Runnable Threads

在调用start()方法,线程变为runnable状态,无论它是否真的在运行(依赖操作系统是否给时间片)。一个线程开始运行之后,不需要不停地运行,即需要暂停使其它线程继续工作

线程调度依赖操作系统(Preemptive--抢占式,给每个Runnable线程时间片来执行任务,当时间片用完,那么操作系统抢过线程的执行然后给其它线程机会工作。在给其它线程时间片时,会考虑线程的优先级)

大部分桌面和服务器系统的线程调度都是抢占式的,但是一些小型设备可能采用合作(cooperative)调度,当一个线程显式调用yield()(静态)方法以让出控制权(或者block/wait)

多处理器的机器可以同时(并行)执行多个线程,当线程数高于进程数,操作系统还是会进行线程调度

Blocked and Waiting Threads

当一个线程处于blocked或waiting状态,它暂时处于不活跃状态,并且消耗极小的资源,需要依靠调度系统来重新激活。详细信息和它是如何进入不活跃状态有关

  1. 当一个线程尝试获取其它线程拥有的内部对象锁(synchronized关键字)时,它变成blocked状态。当所有其它线程释放锁之后并且线程调度器允许这个线程持有,这个线程变为unblocked
  2. 当一个线程在等待其它线程通知一个条件(condition)调度器,它进入了waiting状态。这是通过Object.wait()Thread.join()方法来实现(并行包中的Lock和Condition),实际上这两个状态的差别并不关键
  3. 一些方法有超时时间参数,调用它们导致线程进入timed waiting状态,这个状态持续到超时时间过去或者一个通知到达。这些超时方法包括(Thread.sleep 带超时版本的Object.wait Thread.join Lock.tryLock Condition.await

Terminated Threads

线程终止的两个原因

  1. 自然死亡(run方法执行完成)
  2. 因为run方法中没有捕获的异常导致线程终止

stop方法通过抛出一个ThreadDeath异常来让线程终止(过期,别用)

线程属性

中断状态、守护线程、处理非受检异常、不应该使用的历史特性

中断线程

interrupt方法可以被用来请求(request)线程的中断。当一个线程的interrupt方法被调用,线程的中断状态会被设置。每个线程都有一个boolean值的flag来表示状态,每个线程都需要时常检查它是否被中断

查看中断状态是否被设置

if (!Thread.currentThread().isInterrupted()) {
    do more work;
}

如果一个线程被阻塞,那么它不能检测中断状态。当interrupt方法在被sleepwait的阻塞的线程中被调用,这个阻塞的(线程)调用会被InterruptedException终止(有很多不能被中断的阻塞式的IO,最好可以替换成可中断的)

中断一个线程是为了引起注意。被中断的线程可以决定如果应对这个中断。有些线程十分重要以至于要捕获了InterruptedException之后继续运行。更多的情况是线程只想把这个方法当作一个终止的请求

Runnable r = () -> {
    try
    {
        . . .
        while (!Thread.currentThread().isInterrupted() && more work to do
        {
            do more work
        }
    }
    catch(InterruptedException e)
    {
        // thread was interrupted during sleep or wait
    }
    finally
    {
        cleanup, if required
    }
    // exiting the run method terminates the thread
};

如果在每次任务执行后都调用了sleep(其它可中断的方法),那么isInterrupted方法是没有意义的,如果中断状态已经被设置了,调用sleep,它不会有睡眠行为,而是清除了中断状态,并且抛出了InterruptedException。因此,如果每次循环都要调用sleep,就不需要检查中断状态(状态被清除)

interrupted(静态)方法返回中断状态,然后会清除此状态

有很多代码:

void mySubTask()
{
    . . .
    try { sleep(delay); }
    catch (InterruptedException e) {} // don't ignore!
    . . .
}

InterruptedException不应该在低层次直接捕获掉并且不做任何动作

如果在catch语句块中没有事情做,有两个选择

  1. 调用Thread.currentThread.interrupt();方法来设置中断状态
  2. 更好的选择,抛弃try,在方法上标记throws InterruptedException(让run方法来处理)

守护线程

t.setDeamon(true)可以将一个线程转变为守护线程

守护线程只担任服务其它线程的角色。一般例子:计时器,给其它线程发送“timer ticks”。或者删除无效缓存的线程。当只有守护线程存在,虚拟机会退出。setDaemon方法必须在开启线程前调用

线程名

t.setName("name")来设定线程名

非受检异常处理器(handler)

线程的run方法是不能抛出任何受检异常,但是它可以被非受检异常被终止,这种情况下,线程死亡

catch语句不是用来传递异常的,所以应该在线程死亡前,将异常传入一个用来处理(非受检uncaught)的handler

这个handler必须属于一个实现了Thread.UncaughtExceptionHandler接口(单一方法void uncaughtException(Thread t, Throwable e)

setUncaughtExceptionHandler方法用来给线程设置handler
setDefaultUncaughtExceptionHandler用来设置默认的handler

可以使用handler来将异常信息打印到log文件中

如果不设置默认的handler,默认handler是null,如果不给每个线程单独设置,那么handler是线程的ThreadGroup对象

一个线程组是一组可以统一管理的线程,默认下所有创建的线程属于一个线程组,但是也可以创建其它的线程组。以为现在对于线程集合有更好的操作,所以不建议使用线程组

ThreadGroup实现了Thread.UncaughtExceptionHandler接口,它的uncaughtException方法有以下行为:

  1. 如果线程组有父组,会调用父组的方法
  2. 否则,如果默认handler返回非null的handler,会调用这个
  3. 否则,如果ThrowableThreadDeath的实例,那么什么都不发生
  4. 否则,打印错误堆栈到System.err

线程优先级

Java中每个线程都有优先级,默认情况每个线程继承创建它的线程的优先级,可以通过setPriority方法来设置优先级,范围是MIN_PRIORITY(1)-MAX_PRIORITY(10),NORM_PRIORITY(5)

当线程调度器选择一个新线程时,会优先采用优先级高的线程(实现依赖宿主系统,Java的优先级会映射宿主系统的优先级)

建议现在不再使用优先级(曾经Java不使用操作系统线程(应该是不直接使用))

同步

在实际的多线程程序中,两个或多个线程需要访问相同的数据资源。如果两个线程同时访问并且修改同一个数据,会发什么什么事情?根据数据被访问的顺序,结果将会是不可预测的数据。这种情况被称作条件竞争(race condition)

一个例子

public static void main(String[] args)
{
    var bank = new Bank(NACCOUNTS, INITIAL_BALANCE);
    for (int i = 0; i < NACCOUNTS; i++)
    {
        int fromAccount = i;
        Runnable r = () -> {
            try
            {
                while (true)
                {
                    int toAccount = (int) (bank.size() * Math.random());
                    double amount = MAX_AMOUNT * Math.random();
                    bank.transfer(fromAccount, toAccount, amount);
                    Thread.sleep((int) (DELAY * Math.random()));
                }
            }
            catch (InterruptedException e)
            {
            }
        };
        var t = new Thread(r);
        t.start();
    }
}

这里运行代码的结果是,存款总额无法保证不变。这里的问题是,转账操作时,无论是转入的操作还是转出的操作,都有一个先加/减之后再赋值的操作,此时如果赋值前另一个转账操作在此账号执行,很有可能这个执行结果会被之前未完成的赋值操作覆盖

条件竞争的解释

之前的问题核心在于accounts[to] += amount;不是一个原子操作

javap -c -v Bank 可以反编译Bank.class文件

在多核处理器的条件下,发生冲突的概率是很高的。我们只要能保证在线程失去CPU之前把任务完成,就可以解决冲突问题

锁对象

有两种机制可以在并行过程中保护代码块

  1. synchronized关键字(自动提供一个锁并且关联一个”condition“)
  2. Java5提供的ReentrantLock

java.util.concurrent提供了这些基本机制的分离出来的类

ReentrantLock的保护代码

myLock.lock(); // a ReentrantLock object
try
{
    critical section
}
finally
{
    myLock.unlock(); // make sure the lock is unlocked even if an exception is thrown
}

这个代码结构保证了同一时间只有一个线程能进入关键代码。只要一个线程锁了锁对象,那么其它的线程无法通过lock()代码,只有当持有锁的进程解锁,其它线程才会被激活

使用锁是不能结合try-with-resource结构,因为解锁不是close()方法,即使改名字也不能用,因为它的资源是一个新变量,而锁是被共用的

锁对象应该定义在共享资源当中!!!

这个锁叫做可重入锁,即一个线程可以重复获取它已经拥有的锁。锁中有hold count来保持对lock()方法的嵌套调用(锁1-锁2-释放锁2-释放锁1)

注意:要保证关键代码不会被抛出的异常而避出,如果在代码结束之前抛出了异常,那么finally语句中会释放锁(这个锁会处于损坏状态(行为不可控))

公平听起来很美好,但是公平的锁比普通锁要慢很多。即使使用了公平锁,也不能保证线程调度器是公平的,如果线程调度器忽视了一个等待时间过长的线程(它没有机会得到锁的公平对待)

条件对象(Condition object)

一般来说,一个线程进入关键代码块会出现一种情况,即需要满足特定条件才能继续进行。使用条件对象来管理那些持有锁但是没办法进行工作的线程

例子:当银行账户钱不够时,不能转账

if (bank.getBalance(from) >= amount)
    bank.transfer(from, to, amount);

这样写代码在多线程环境下,一个线程在判断成功之后完全有可能被中断,然后导致错误结果

public void transfer(int from, int to, int amount)
{
    bankLock.lock();
    try
    {
        while (accounts[from] < amount)
        {
        // wait
        . . .
        }
    // transfer funds
    . . .
    }
    finally
    {
        bankLock.unlock();
    }
}

可以将测试条件跟执行代码部分放在关键代码块中。此时,如果余额不够的话,只能等待其它的线程把钱转过来,但是这个线程拥有的是排他锁,所以别的线程没办法执行代码。这种情况下条件对象的意义就凸显出来了

一个锁对象可以有一个或多个关联的条件对象,通过newCondition方法来获得条件对象(类型为Condition

class Bank
{
    private Condition sufficientFunds;
    . . .
    public Bank()
    {
        . . .
        sufficientFunds = bankLock.newCondition();
    }
}

public void transfer(int from, int to, int amount)
{
    bankLock.lock();
    try
    {
        while (accounts[from] < amount)
            sufficientFunds.await();
        // transfer funds
        . . .
        sufficientFunds.signalAll();
    }
    finally
    {
        bankLock.unlock();
    }
}

当发现余额没有转账数额高,调用await方法,此时当前线程失活并且释放锁。使得其它线程可以(有几率)给这个账户转账

当一个线程调用了await方法,它进入了这个条件的等待(waiting)集,这个线程不会在锁可用时直接进入可运行态,而是在其它线程调用了这个条件对象的signalAll方法。这个方法激活了所有等待这个条件的线程,当线程被移出等待集后,就变成可运行态,等待调度器来调度。只要锁可用,它们中的一个会获得锁并从离开的地方继续运行(回到调用await的地方)

一般来说,调用await应该在一个循环中

这种使用的情景是有可能发生死锁的

什么时候调用signalAll方法?一般来说,只要一个对象的状态以一种会对等待线程有意义的方式发生了改变,那就需要调用这个方法(给别的线程机会进行再次判断),在转账的场景下,每次成功的转账行为之后都要调用这个方法

signal方法只解锁等待集中的单一线程(随机),如果这个线程发现条件依旧不满足,那么它会阻塞,如果没有其它线程再signal,那么这个系统死锁

synchronized关键字

LockCondition的关键点:

  1. 一个锁保护一段代码,使其在同一时间只能有一个线程访问
  2. 锁管理那些尝试进入被保护代码的线程
  3. 一个锁可以有一个或多个关联的条件对象
  4. 每个条件对象管理已经进入保护代码但是不能继续运行的线程

java1.0开始每个对象都有一个内部(intrinsic)锁,如果一个方法被声明为synchronized,那么这个对象的锁保护整个方法(要调用这个方法,必须持有这个锁)

内部锁有一个关联的条件(对象)。wait方法将一个线程添加到等待集合,notifyAll/notify方法unblocking等待的线程

// wait和notify等于同
intrinsicCondition.await();
intrinsicCondition.signalAll();

将静态方法声明为synchronized也是允许的,此时内部锁是关联的类对象,这种情况下,当一个线程在运行这个静态方法,那么类中其它的同步静态方法是没办法被调用的

限制:

  1. 不能中断一个尝试获得锁的线程
  2. 不能给尝试获得锁的行为设置超时
  3. 每个内部锁只有1个条件,并不高效(复杂逻辑会浪费时间)

使用同步还是Lock+Condition

  1. 最好都不用(滑稽脸),即不手工控制。尽量使用java.util.concurrent包中提供的结构
  2. synchronized代码可以让事情变得更简单(好读),那么使用
  3. Lock/Condition,如果需要它提供的额外的功能(多条件)

Synchronized代码块

synchronized (obj) // this is the syntax for a synchronized block
{
    critical section
}

当一个线程进入这个代码块,它获得的是参数对象的锁

ad hoc(临时安排的,专门的)对象的创建是专门作为锁使用。即Object o = new Object()

客户端(client-side)的锁是很脆弱的(不建议使用)。Vector类中的方法是synchronized,但是也不确定所有的过程都利用了内部锁,还是需要查看源码

Monitor概念

Monitor是为了解决(锁/条件模式不太符合面向对象思维)问题而提出的新概念(1970)。在Java中,它有如下属性

  • 一个Monitor是一个只有私有成变量的类
  • 这个类的每个对象都有关联的锁
  • 所有方法都被这个锁锁定。因为所有的成员变量都是私有的,所以没有线程可以在另外的线程处理时访问成员变量
  • 这个锁可以有多个条件

Java中的synchronized关键字和Monitor的区别

  1. 成员变量不需要是private
  2. 方法也不需要全部是synchronized
  3. 内部锁的控制给了调用方(client)

Volatile Fields

有时如果只是读写一两个实例成员变量,那么同步所花的性能代价有些高。然而在现代处理器和编译器的环境下,很多地方可能出错

  1. 多核处理器可能会暂时将内存值存在寄存器或者内存缓存中,那么在不同核上运行的线程很可能看到的值不同
  2. 编译器为了提高吞吐率,可能会改变指令顺序(不改变语意),但是它的假设前提是内存的值只会被显式指令改变,然而实际上它可以被其它线程改变

volatile声明的成员变量被编译器认为(会被其它线程并行更新的值)。编译器会增加一些代码来保证对这个变量的修改会被其它线程看到

Final Variables

当使用final来声明一个成员变量时,只有当构造器调用完成,其它的线程才能看到

Atomics

java.util.concurrent.atomic中的很多类, 使用效率很高的机器级指令在不使用锁的情况下保证对原子操作

AtomicLong类的incrementAndGet返回增加后的值。这个(取值,+1,设置值,得到新值)过程不能被中断。即使多线程并行访问这个实例

compareAndSet可以进行更复杂的操作

public static AtomicLong largest = new AtomicLong();
// in some thread. . . 不是原子操作
largest.set(Math.max(largest.get(), observed)); // ERROR-race condition!

// 成功
largest.updateAndGet(x -> Math.max(x, observed));
// accumulateAndGet提供一个二元方法应用于atomic值和给定值
largest.accumulateAndGet(observed, Math::max);

// 其它的一些类
AtomicInteger
AtomicIntegerArray
AtomicIntegerFieldUpdater
AtomicLongArray
AtomicLongFieldUpdater
AtomicReference
AtomicReferenceArray
AtomicReferenceFieldUpdater

LongAdder LongAccumulator解决了(大量线访问同一个原子值,太多的重试(CAS))性能问题。LongAdder包含多个变量,它们的总和是当前值,多个线程可以更新不同的被加数(提高吞吐量),当线程变多的时候,会自动添加新的被加数。在所有工作完成之后才要获取值的情况,这样会提高效率

var adder = new LongAdder();
for (. . .)
pool.submit(() -> {
    while (. . .) {
        . . .
        if (. . .) adder.increment();
    }
});
. . .
long total = adder.sum();

LongAccumulator泛化了这个概念到所有的聚合操作

死锁

当所有线程等待且永远不能被唤醒,此时发生了死锁

避免死锁只能通过逻辑设计

Thread-Local Variables

ThreadLocal,给每个线程一个不同的对象

举例:

SimpleDateFormat类不是线程安全的,如果有一个静态变量public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
如果两个线程执行了String dateStamp = dateFormat.format(new Date());,之后结果可能不能使用。因为dataFormat的数据结构会被并行访问破坏。如果用同步操作或者随用随创建对象,那么性能开销比较高

public static final ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));可以给每个线程一个实例。使用String dateStamp = dateFormat.get().format(new Date());来调用(get方法)

还有一个场景是生成随机数(Random类是线程安全的,但是所有线程共享一个生成器还是效率很低的)

// 返回Random类的一个实例
int random = ThreadLocalRandom.current().nextInt(upperBound)

为什么stop和suspend方法过时了

stop方法用来中断线程。suspend方法用来阻塞线程,直到另一个线程调用了resume。它们的共同点是:都希望不通过接触(cooperation)线程来控制线程

stop是内在不安全的,suspend经常引起死锁

stop方法

这个方法中断所有的pending方法(run方法)(通过throw ThreadDeath异常)。当一个线程被停止,它立即放弃所有加在对象上的锁。这可能会使对象处于不一致的状态(一个线程在转账,从账户中取钱之后线程停止,那么另一个线程能看到没有加钱的账户,此时银行对象被破坏了)

当一个线程想停止另一个线程,没办法知道这个stop方法调用是否安全,所以这个方法过时了。当你想停止一个线程,那么中断它,被中断的线程在安全状态下会被停止

suspend

这个方法不会破坏对象,如果暂停一个拥有锁的线程,那么在这个线程被回复之前这个锁不能被其它线程持有

这里举得例子是图形化接口:

假设有两个按钮,一个负责暂停转账,一个负责继续转账

pauseButton.addActionListener(event -> {
for (int i = 0; i < threads.length; i++)
    threads[i].suspend(); // don't do this
});
resumeButton.addActionListener(event -> {
for (int i = 0; i < threads.length; i++)
    threads[i].resume();
});

paintComponent给每一个账户画一张图表,调用getBalances来获取余额数组

前提:按钮动作和画图是在同一线程(event dispatch thread)中进行

  1. 一个转账线程持有bank锁
  2. 用户点了暂停
  3. 所有转账线程暂停,其中一个持有锁
  4. 某账户需要画图
  5. 此方法调用getBalances方法
  6. 这个方法尝试持有bank锁

此时event dispatch thread也不能继续,因为锁被暂停线程持有,因此用户也没办法点继续按钮

如果想做这样的暂停行为,可以设置一个变量,此变量在run方法中的安全位置(不持有其它线程需要的锁)被测试,如果被设置为暂停,那么在它被唤醒之前应该进行等待

线程安全的集合

如果多个线程同时修改一个数据结构,例如哈希表,那么很容易破坏这个数据结构(如果一个线程在往hash表中插入元素,在哈希表的“桶”之间的链表进行rerouting(重分布/树结构)时,另一个线程开始遍历这个hash表,那么可能会按照一个无效的顺序遍历,可能抛出异常或者陷入无限循环)

阻塞队列

很多线程问题可以使用队列解决。例如

  1. 生产者生产之后推到队列,消费者取
  2. 一个线程将银行转账指令对象推入队列,另一个执行线程在线程中取一个对象(完成转账操作,只有此线程可以访问bank对象内部状态),此时没有同步问题

一个阻塞队列(blocking queue),在队列满且添加元素和队列空移除元素的时候都会阻塞线程。阻塞队列同时也在平衡负载 (一些工作线程将中间结果插入队列,另一些取结果,无论哪方比较快,在无事可做的情况下都会被阻塞)

方法名 行为 特殊情况行为
add 添加元素 如果满抛出IllegalStateException
element 返回队首元素 如果空抛出NoSuchElementException
offer 添加元素返回true 如果满返回false
peek 返回头元素 如果空返回null
poll 移除并且返回队首元素 如果空返回null
put 添加元素 如果满阻塞
remove 移除并返回队首元素 如果空抛出NoSuchElementException
take 移除并返回队首元素 如果空阻塞

put take可以当作线程管理工具(阻塞行为)
add remove element会抛出异常(一般操作)
offer poll peek 多线程情况下使用(返回null/true false)

超时版本的offer poll

// 如果插入(队尾)成功返回true,否则超时返回false
boolean success = q.offer(x, 100, TimeUnit.MILLISECONDS);
// 移除并返回(队首)元素,成功返回对象,否则(移除)超时返回null
Object head = q.poll(100, TimeUnit.MILLISECONDS);

put take等同于没有超时的offer poll

java.util.concurrent提供了一些变种:

LinkedBlockingQueue容量不设上限,但是可以设置最大容量
LinkedBlockingDeque双端队列版本
ArrayBlockingQueue给定容量的队列,有一个公平性可选参数(如果设置,那么长时间等待的线程会被更优先给与权限。这个特性存在严重的性能问题)
PriorityBlockingQueue不遵循先进先出原则,而是根据优先级被移除。队列不设容量,但如果队列为空,取数据会被阻塞
DelayQueue的对象实现了Delayed接口。只有过了延时的元素才能被移除队列(需要实现compareTo方法,使用它来对元素排序)

interface Delayed extends Comparable<Delayed>
{
    // 返回对象剩余的延时,负数表示延时已经过了
    long getDelay(TimeUnit unit);
}

Java7的TransferQueue接口,允许生产者线程等待消费者线程准备好接收再生产

// 生产者调用这个方法,嗲用会被阻塞,直到其它线程把他拿走
q.transfer(item);

LinkedTransferQueue实现了这个接口

书里的代码例子是一个线程遍历文件夹,然后将文件名路径推到队列中,然后多个线程取队列中的文件,将包含某个关键字的行输出。其中值得记录的是如何结束数个消费者进程:生产者如果遍历完了,那么将一个无效路径插入队列,当有一个消费者线程拿到这个路径,则设置退出标志,将此对象重新插入队列并且结束线程

高效的Maps Sets Queues

java.util.concurrent包中提供了高效率的Maps Sets Queues

ConcurrentHashMap
ConcurrentSkipListMap 
ConcurrentSkipListSet
ConcurrentLinkedQueue

这些集合使用复杂算法来最小化并行访问数据结构不同部分导致的竞争。和大部分集合不同,它们的size方法消耗的时间一般不是常数级别(需要遍历)

超大(20亿数据以上)的map,使用mappingCount来获取大小(long)普通的size返回的是int

这些集合返回弱一致性的迭代器(不一定能看到所有的改动--在迭代器被创建之后)。但是它们不会返回一个值两次(也不会抛出ConcurrentModificationException)

并发hashmap可以高效支持大量的读者线程和固定数量的写者线程(同时存在16个写者)如果超过这个数字,那么超出的线程会被(暂时)阻塞(可以在构造器中指定最大参数)

现在的Java版本中,map中的桶不再用链表处理而使用树结构

Map的entry的原子更新操作

ConcurrentHashMap原本提供进行原子更新操作的方法很少

如果想记录某个值出现的次数

ConcurrentHashMap<String, Long>

Long oldValue = map.get(word);
Long newValue = oldValue == null ? 1 : oldValue + 1;
map.put(word, newValue); // ERROR--might not replace oldValue

thread-safe的数据结构保证的是所有的操作不会破坏数据结构内部的数据组织,而不保证所有的操作都是原子操作(线程安全操作)

老版本Java的解决办法

do
{
    oldValue = map.get(word);
    newValue = oldValue == null ? 1 : oldValue + 1;
}
while (!map.replace(word, oldValue, newValue));

替代方案

// 每次都要新建AtomicLong对象
map.putIfAbsent(word, new AtomicLong());
map.get(word).incrementAndGet();

目前的方法

// 第二个lambda表达式的参数中的v可能是null或者map.get(k)
map.compute(word, (k, v) -> v == null ? 1 : v + 1);

ConcurrentHashMap的值不能是null

computeIfPresent有旧值的计算
computeIfAbsent没有旧值的计算

LongAdder的map更新值可以使用map.computeIfAbsent(word, k -> new LongAdder()).increment();的方式,LongAdder的对象只有在需要新计数器的情况下才会创建

merge方法有一个参数是初始值(当这个key不存在)。否则会调用提供的方法,组合新值和初始值(和compute不同,它不处理key)map.merge(word, 1L, (existingValue, newValue) -> existingValue + newValue);

如果传入compute或者merge方法返回null,那么这个entry已经被删除。在提供的方法里最好没有过于复杂的逻辑,因为其它对这个map的更新操作可能会被阻塞

对Concurrent Hash Maps的批量操作

JavaAPI提供的对并发hashmap的批量操作方法可以在其它线程操作map的时候安全执行

search 对每一个键/值应用函数,直到函数返回一个非null结果,然后搜索终止函数结果被返回
reduce 使用给定的聚合函数将每个键/值计算完之后返回
forEach 对每个键/值应用函数

以上三个方法都有四个版本

  1. keys
  2. values
  3. keys and values
  4. Map.Entry

每个操作都要指定并行门槛,如果map包含的数据超过了这个门槛,那么这个操作会并行执行,如果希望这个操作单线程执行,那么设置门槛为Long.MAX_VALUE,如果希望使用最大数量线程运行,那么设为1

forEach方法可以提供过滤函数,例如map.forEach(threshold, (k, v) -> v > 1000 ? k + " -> " + v : null, // filter and transformer System.out::println); // the nulls are not passed to the consumer

reduce方法也可以提供过滤器

如果map为空或者所有元素被过滤,那么reduce方法返回null,如果只有1个元素,它的转换函数会返回,聚合器不会应用

对基本数据类型输出可以提供默认值

long sum = map.reduceValuesToLong(threshold,
    Long::longValue, // transformer to primitive type
    0, // default value for empty map
    Long::sum); // primitive type accumulator

Concurrent Set Views

如果希望有一个数量非常大的,线程安全的set,但是没有ConcurrentHashSet类。可以通过ConcurrentHashMap存储虚假值,key当set用,但是不能应用Set接口的方法

// 生成一个Set,其实是ConcurrentHashMap<K, Boolean>的wrapper,所有的值是Boolean.true,并不需要考虑
Set<String> words = ConcurrentHashMap.<String>newKeySet();

如果已经有一个map,那么keySet方法返回一个key的set,这个set是可变的,即从这里删除一个元素,那么对应map的键值对也会被删除

// 带默认值的方法,得到的keySet往里加一个元素,如果这个key不存在,那么会添加一个值为默认值的键值对
Set<String> words = map.keySet(1L);
words.add("Java");

Copy on write Arrays

// 线程安全的集合,所有修改它的线程都会基于原始数组做一个备份
// 如果遍历的线程比修改的线程数量多得多,这两个数据结构是没有同步操作开销的
// 遍历的线程在开始遍历之后,数组被其它线程修改,那么看到的(可能)是旧值
CopyOnWriteArrayList
CopyOnWriteArraySet

Parallel Array Algorithms

Arrays类有一些并行操作

var contents = new String(Files.readAllBytes(Path.of("alice.txt")), StandardCharsets.UTF_8); // read file into string
String[] words = contents.split("[\\P{L}]+"); // split along nonletters
Arrays.parallelSort(words);
// 可以自己提供Comparator
Arrays.parallelSort(words, Comparator.comparing(String::length));
// 可以自己定义范围
values.parallelSort(values.length / 2, values.length); // sort the upper half
// 用函数计算出来的值来填充数组,函数接收索引计算值
Arrays.parallelSetAll(values, i -> i % 10);
// fills values with 0 1 2 3 4 5 6 7 8 9 0 1 2 . . .
// 此方法将每个元素替换为给定函数计算出的prefix的聚合结果
// value [1, 2, 3, 4, . . .] 操作 *
// [1, 1 × 2, 1 × 2 × 3, 1 × 2 × 3 × 4, . . .]
// 这个计算是可以并行的,将其分为两个一组,则[1,1*2,3,3*4,5,5*6]可以先算乘法,然后再进行相乘之后的结果可以重复此过程
Arrays.parallelPrefix(values, (x, y)-> x * y)

老的线程安全集合

Vector Hashtable两个类提供了线程安全的动态数组和hash表,现在已经被ArrayList HashMap代替,这些类都不是线程安全的,在集合库中提供了不同的机制。任何集合类可以通过synchronization wrapper来变成线程安全的

List<E> synchArrayList = Collections.synchronizedList(newArrayList<E>());
Map<K, V> synchHashMap = Collections.synchronizedMap(new HashMap<K,V>());

最好还是使用java.util.concurrent包中的类

例外:如果有频繁改变的动态数组(arraylist),那么使用synchronized ArrayList要比使用copyOnWriteArrayList要好

Task and Thread Pools

由于创建新线程会和操作系统交互,所以代价比较高。如果程序需要大量的持续时间短的线程,此时适合使用线程池。一个线程池包括大量随时准备运行的线程。传入一个Runnable对象给线程池,然后一个线程会调用它的run方法,当run方法退出,线程还会继续服务下一个请求

Callables and Futures

Runnable异步处理一个任务,可以把它当作一个没有参数没有返回值的异步方法。Callable和它相似,但是它有返回值。这个接口是泛型实现的,其中只有一个方法

public interface Callable<V>
{
    V call() throws Exception;
}

它的类型参数的类型是返回值的类型

Future持有异步计算的结果,开始计算后,给其它位置一个Future对象,然后就可以不管它了。持有这个Future对象的人当计算完成可以获得返回值

// Future接口包含的方法
// 直到计算完成前调用这个方法会阻塞
V get()
// 也会阻塞,如果调用超时(计算完成前)会抛出TimeoutException
// 这两个方法如果计算线程被中断,都会抛出InterruptedException
V get(long timeout, TimeUnit unit)
// 取消方法(如果没开始,则不会开始,设置参数为true,会发生线程中断)
void cancel(boolean mayInterrupt)
boolean isCancelled()
// 计算还在进行返回false
boolean isDone()

取消一个任务分两步,运行的线程必须被找到并且被中断。任务的实现(run)必须意识到中断并且抛弃work。如果Future对象不知道哪个线程在运行任务或者任务没有监控中断状态,那么cancel方法可能没用

执行Callable的方法:

Callable<Integer> task = . . .;
// 实现了Future接口和Runnable接口
var futureTask = new FutureTask<Integer>(task);
var t = new Thread(futureTask); // it's a Runnable
t.start();
. . .
Integer result = task.get(); // it's a Future

Executors

Excutors有很多静态方法来创建一个线程池

方法 解释
newCachedThreadPool 需要时创建线程,空闲线程等待60s
newFixedThreadPool 线程池保持线程,空闲线程无限等待
newWorkStealingPool fork-join的任务,即复杂任务分成简单任务,空闲线程"steal"简单任务
newSingleThreadExecutor 一个线程,顺序执行提交的任务
newScheduledThreadPool fix线程池,处理调度任务
newSingleThreadScheduledExecutor 单线程的线程池处理调度任务

newCachedThreadPool创建一个线程池,立即执行任务,如果有空闲线程用空闲,否则新建线程
newFixedThreadPool创建线程数量固定的线程池,如果任务数量大于线程数量,它们h会被放到等待队列,当其它任务完成再运行
newSingleThreadExecutor是一个线程数量为1的退化的线程池,顺序执行任务

这三个方法返回的是实现了ExecutorServiceThreadPoolExecutor对象

当你有存活时间短或者大量时间在阻塞,那么使用缓存线程池。然而,如果线程必须阻塞,那么同时创建大量阻塞线程并不可取。为了优化速度,并行线程数量应该等于核心数量,这种情况下应该使用固定大小线程池。单一线程池在性能分析中很有用(暂时将缓存/固定大小线程池换为单一线程池,可以测试没有并行的程序有多慢)

Future<T> submit(Callable<T> task)
// 返回值是null(任务完成),其它检查运行状态的方法可以正常调用
Future<?> submit(Runnable task)
// 任务完成返回的是参数提供的对象
Future<T> submit(Runnable task, T result)

shutdown来关闭线程池,这个方法会初始化关闭顺序,被关闭的executor不能接收任务,当所有任务执行完毕(调用shutdownNow,线程池取消所有还没开始的任务),线程池死亡

怎么使用线程池:

  1. 调用Executors的静态方法newCachedThreadPool newFixedThreadPool
  2. 调用submit方法来提交Callable Runnable
  3. 持有返回的Future对象,可以获取返回值或者取消任务
  4. 不再提交任何任务时,调用shutdown

ScheduledExecutorService接口提供定时或者重复执行任务的方法,属于java.util.Timer的线程池应用扩展

newScheduledThreadPool newSingleThreadScheduledExecutor方法返回实现了这个接口的对象

它们的功能包括:只运行一次、某个时间后运行、定时周期性运行(API详解)

控制任务组(Groups of Tasks)

有时,Executor可以用来控制一组相关联的任务

invokeAny提交一个Callable集合,返回一个完成的任务的结果(不知道是哪个,执行最快的)使用这个方法可以查出哪种解决方法是最快的
invokeAll提交一个Callable集合,当所有任务完成会返回完成任务的集合(完成之前处于堵塞状态)

List<Callable<T>> tasks = . . .;
List<Future<T>> results = executor.invokeAll(tasks);
for (Future<T> result : results)
    // get方法会阻塞
    processFurther(result.get());

以完成顺序获得任务的结果。使用ExecutorCompletionService

// service管理了一个Future对象的阻塞队列
var service = new ExecutorCompletionService<T>(executor);
for (Callable<T> task : tasks) service.submit(task);
for (int i = 0; i < tasks.size(); i++)
    processFurther(service.take().get());

一个任务的写法示例

public static Callable<Path> searchForTask(String word, Path path)
{
    return () -> {
        try (var in = new Scanner(path))
        {
            while (in.hasNext())
            {
                if (in.next().equals(word)) return path;
                // 要监控线程的中断状态(服务cancel)
                if (Thread.currentThread().isInterrupted())
                {
                    System.out.println("Search in " + path + " canceled.");
                    return null;
                }
            }
            throw new NoSuchElementException();
        }
    };
}

使用线程池而不要独立创建新线程

Fork-Join框架

有些应用使用大量(大部分时间是)空闲的线程。例如Web服务器给每个连接一个线程,其它的应用每个处理器核心一个线程,为了处理计算压力大的任务,例如图像或者视频处理

Java7出现的fork-join框架,是为了支持后一种模式

如果有一个待处理的任务天然可以分为简单的子任务

if (problemSize < threshold)
    solve problem directly
else
{
    break problem into subproblems
    recursively solve each subproblem
    combine the results
}

例如图片处理,可以将图片一分为二处理。如果有足够的空闲处理器,那么这个操作可以并行处理

另一个例子:我们想算出一个数组中有多少元素满足一个特定的属性,先将数组一分为二,计算每组的值然后加起来,为了让递归计算过程可以被框架使用,提供一个继承了RecursiveTask<T>(返回值类型为T)的类或者RecursiveAction(没有返回值)。覆写compute方法来生成和调用子任务,然后合并

class Counter extends RecursiveTask<Integer>
{
    . . .
    protected Integer compute()
    {
        if (to - from < THRESHOLD)
        {
            solve problem directly
        }
        else
        {
            int mid = (from + to) / 2;
            var first = new Counter(values, from, mid, filter);
            var second = new Counter(values, mid, to, filter);
            // 接收一些任务,在它们全部完成之前阻塞
            invokeAll(first, second);
            // join方法返回结果
            return first.join() + second.join();
        }
    }
}

get方法也可以获得结果,但是它有受检异常,且compute方法不允许抛出受检异常

在代码背后,fork-join框架使用了高效的(有启发意义的)被叫做work stealing的方式,来平衡工作负载。每个worker线程有一个双端队列来放任务,一个工作线程将子任务放在它自己的队列头(同时只有一个线程能访问头),当工作线程空闲,它会从另一个线程的尾“偷”一个任务执行,因为大的子任务在尾部,这种“偷”的行为并不常见

Fork-join线程池对于非阻塞的工作负载进行了优化,如果添加很多阻塞任务进这个线程池,会马上把资源耗尽,可以通过让任务实现ForJoinPool.ManagedBlocker接口(高级话题)

异步计算

Completable Futures

CompletableFuture类实现了Future接口,并且提供了第二种种机制(提供一个回调函数,当某个线程的任务完成了,则会调用此函数)来获取结果

CompletableFuture<String> f = . . .;
f.thenAccept(s -> Process the result string s);

这种情况下,不需要阻塞等待任务完成

HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(urlString)).GET().build();
// 异步获取返回值
CompletableFuture<HttpResponse<String>> f = client.sendAsync(
request, BodyHandler.asString());

要异步运行一个任务并且获得CompletableFuture对象,应该调用静态方法CompletableFuture.supplyAsync

public CompletableFuture<String> readPage(URL url)
{
    return CompletableFuture.supplyAsync(() ->
    {
        try
        {
            return new String(url.openStream().readAllBytes(), "UTF-8");
        }
        catch (IOException e)
        {
            throw new UncheckedIOException(e);
        }
    }, executor);
}

如果忽略executor,任务会运行在默认的executor(ForkJoinPool.commonPool())下,一般不会这样做

supplyAsync的第一个参数是Supplier<T>,而不是Callable<T>。因为后者的实现方法是不允许抛出受检异常的

CompletableFuture以两种方式完成

  1. 得到一个结果
  2. 未捕获的异常

为了处理这两种情况,使用whenComplete方法。提供的函数参数会在有返回值或者出现异常的时候调用

f.whenComplete((s, t) -> {
if (t == null) { Process the result s; }
else { Process the Throwable t; }
});

CompletableFuture对象可以手工设置一个完成的值

var f = new CompletableFuture<Integer>();
executor.execute(() ->
    {
        int n = workHard(arg);
        f.complete(n);
    });
executor.execute(() ->
    {
        int n = workSmart(arg);
        f.complete(n);
    });

以一个异常来完成future

Throwable t = . . .;
f.completeExceptionally(t);

在多线程中调用同一个future的complete completeExceptionally是安全的,如果future已经完成,那么这些方法没有作用

isDone方法得到一个Future对象是否已经完成(正常或异常)

CompletableFuture的计算在调用cancel方法后不会被中断,这个方法只是简单将Future对象设为异常结束(CancellationException

Composing Completable Futures(组合)

非阻塞调用通过回调来实现。所以回调地狱出现了(执行顺序)

CompletableFuture通过提供组合异步任务进入处理管道的机制来解决这个问题

例子:想从一个web网页中抽取所有图片

// 返回web页面
public void CompletableFuture<String> readPage(URL url)
// 取出web页面中所有的图片链接
public List<URL> getImageURLs(String page)
CompletableFuture<String> contents = readPage(url);
CompletableFuture<List<URL>> imageURLs =  contents.thenApply(this::getLinks);

thenApply不是阻塞的,它返回另外一个Future,当第一个future完成,它的结果会被传到getImageURLs,然后它的返回值是最后的结果

以下列出一些常用方法

方法 参数 解释
thenApply T -> U 对结果应用这个函数
thenAccept T -> void 和apply类似,没有返回值
thenCompose T -> CompletableFuture 在结果上应用函数并且执行返回的future
handle (T, Throwable) -> U 处理结果或异常,返回新值
whenComplete (T, Throwable) -> void 和handle类似,没有返回值
exceptionally Throwable -> T 从错误中计算(返回)值
completeOnTimeout T, long, TimeUnit 一旦超时返回给定值
orTimeout long, TimeUnit 一旦超时产生TimeoutException
thenRun Runnable 执行Runnable

组合多个组合对象

方法 参数 描述
thenCombine CompletableFuture<U>,(T, U) -> V 执行两个future,然后对两个返回时应用函数
thenAcceptBoth CompletableFuture<U>,(T, U) -> void 和combine类似,但是应用的函数没有返回值
runAfterBoth CompletableFuture<?>,Runnable 两个都完成后调用Runnable
applyToEither CompletableFuture<T>,T -> V 任何一个完成,将其返回值给到方法参数
acceptEither CompletableFuture<T>,T -> void 和either类似,方法参数没有返回值
runAfterEither CompletableFuture<?>,Runnable 在一个或另一个完成后调用Runnable
static allOf CompletableFuture<?>... 所有给定future完成之后,以void返回值结束
static anyOf CompletableFuture<?>... 任一future完成,以void返回值结束

Long-Running Tasks in User Interface Callbacks

使用多线程的一个原因是让程序变为响应式的(responsive)。以GUI为例,很多耗费时间的任务不能在用户接口来运行(阻塞用户操作)

举例:

var open = new JButton("Open");
open.addActionListener(event ->
{ // BAD--long-running action is executed on UI thread
    var in = new Scanner(file);
    while (in.hasNextLine())
    {
        String line = in.nextLine();
        . . .
    }
});

open.addActionListener(event ->
{ // GOOD--long-running action in separate thread
    Runnable task = () ->
    {
        var in = new Scanner(file);
        while (in.hasNextLine())
        {
            String line = in.nextLine();
            . . .
        }
    };
    executor.execute(task);
});

通常,在worker线程中不能更新用户接口的状态(用户接口不是线程安全的)。因此,要安排(schedule)UI线程的更新

// swing中
EventQueue.invokeLater(() -> label.setText(percentage + "%complete"));

UI库一般会提供帮助类来辅助工作,swing -> SwingWorker Task -> JavaFX AsyncTask -> Android

这里的例子是一个GUI的例子,不再记录了

进程

如果需要执行另外一个程序,应该使用ProcessBuilderProcess类。Process类在另外一个操作系统进程中执行命令(和标准输入、输出、错误流交互)。ProcessBuilder类可以配置Process对象

ProcessBuilderRuntime.exec调用效率更高

创建进程

// 组合命令,第一个字符串必须是可执行的命令(不能是shell内置的)。每个进程有一个工作目录,用来解决相对路径,默认工作目录和虚拟机(运行Java程序的目录)一样
var builder = new ProcessBuilder("gcc", "myapp.c");
// 修改工作目录
// 每个配置builder的方法都返回它自身
builder = builder.directory(path.toFile());
// 得到该程序的输入、输出、错误流
// 进程的输入流是 虚拟机的输出流(向这个流中写入数据,JVM会写到那个进程中)
OutputStream processIn = p.getOutputStream();
// 读取进程的输出和错误信息
InputStream processOut = p.getInputStream();
InputStream processErr = p.getErrorStream();
// 如果用户在控制台运行JVM,那么所有的用户输入会转到进程,进程的输出也会显示在控制台
// 对三个流同时设置
builder.redirectIO()
// 对redirectInput redirectOutput redirectError分别设置是否继承(inherit)
builder.redirectOutput(ProcessBuilder.Redirect.INHERIT);
// 将输入输出流重定向到文件,输出和错误的文件当进程开始创建或删除(?)
builder.redirectInput(inputFile).redirectOutput(outputFile).redirectError(errorFile)
// 如果要追加到已经存在的文件
builder.redirectOutput(ProcessBuilder.Redirect.appendTo(outputFile));
// 合并输出流和错误流
builder.redirectErrorStream(true)
// 修改进程的环境变量
Map<String, String> env = builder.environment();
env.put("LANG", "fr_FR");
env.remove("JAVA_HOME");
Process p = builder.start();
// 串联(pipe)一个进程的输出进另一个的输入(|类似),Java9提供了startPipeline方法,传入一个process builder列表,并且读最后一个进程的输出结果
List<Process> processes = ProcessBuilder.startPipeline(List.of(
    new ProcessBuilder("find", "/opt/jdk-9"),
    new ProcessBuilder("grep", "-o", "\\.[^./]*$"),
    new ProcessBuilder("sort"),
    new ProcessBuilder("uniq")
));
Process last = processes.get(processes.size() - 1);
var result = new String(last.getInputStream().readAllBytes());

运行一个进程

Process process = new ProcessBuilder("/bin/ls", "-l")
    .directory(Path.of("/tmp").toFile())
    .start();
// 进程流的缓冲空间优先,不应该填满输入,而且应该马上读取输出。如果有很多的输入和输出,应该利用多线程处理
try (var in = new Scanner(process.getInputStream())) {
    while (in.hasNextLine())
    System.out.println(in.nextLine());
}
// 等待进程完成,返回进程的结束(返回)值
int result = process.waitFor();
// 不想无限等待(没超时返回true)
long delay = . . .;
if (process.waitfor(delay, TimeUnit.SECONDS)) {
    // 获取进程的结果
    int result = process.exitValue();
    . . .
} else {
    process.destroyForcibly();
}

直接让进程运行,调用isAlive来查看它是否还存活,杀死进程使用destroy destroyForcibly(区别是平台依赖的)supportsNormalTermination如果销毁方法可以正常终止进程则返回true

// 当进程结束,接收异步通知
// onExit产生一个CompletableFuture<Process>
process.onExit().thenAccept(p -> System.out.println("Exit value: " + p.exitValue()));

进程处理

要获取自己程序启动的进程或者机器上运行的进程的信息,可以通过ProcessHandle接口

获取ProcessHandle的方法

  1. Process对象p,调用p.toHandle()
  2. 给定一个长的操作系统进程id,ProcessHandle.of(id)
  3. Process.current()运行JVM的进程
  4. ProcessHandle.allProcesses()产生一个Stream<ProcessHandle>获取所有当前进程能看到的所有进程
// 获取进程ID,父进程,子进程,后代进程
// 流中的任一进程信息都是一个获取时快照(不会变化)
long pid = handle.pid();
Optional<ProcessHandle> parent = handle.parent();
Stream<ProcessHandle> children = handle.children();
Stream<ProcessHandle> descendants = handle.descendants();
// info方法产生的是ProcessHandle.Info对象有很多方法获取进程信息
Optional<String[]> arguments()
Optional<String> command()
Optional<String> commandLine()
Optional<String> startInstant()
Optional<String> totalCpuDuration()
Optional<String> user()
// ProcessHandle接口也提供了isAlive, supportsNormalTermination, destroy, destroyForcibly, and onExit来监控或强制进程终止,但是没有Process的waitFor
posted on 2020-11-25 10:59  老鼠不上树  阅读(135)  评论(0)    收藏  举报