那些年你不知道的并发知识(下)

并发基础模块

Semaphore(信号)

注: 并发编程内容

主要方法

Semaphore semaphore = new Semaphore(size);

构造方法, 给一个int类型的参数. 这个参数,代表着信号量的大小
也可以叫做虚拟的许可, 初始化构造许可的大小

semaphore.acquire();

获得许可(只要还有剩余许可), 如果没有许可,那么acquire阻塞直到有许可(或者直到被中断或操作超时)

semaphore.release();

此方法将返回一个信号量

作用

1.做二值信号量(互斥体)

如果Semaphore初始化为1的话, 这个叫做二值信号量可以用作互斥体
并且具备不可重入的加锁语义 : 谁拥有这个唯一的许可就拥有了互斥锁

2.资源池

可以用于实现资源池,例如 数据库连接池. 构造一个固定长度的资源池, 当池为空的时候, 请求资源将会失败
. 但你真正希望看到的是阻塞而不是失败. 并且当非空的时候解除阻塞就可以使用这个
(在构造阻塞对象池时, 一种简单的方法是使用BlockingQueue来保存池的资源)
其方法是使用的时候 调用acquire获得许可, 使用完毕后调用release返回许可

3.给容器加边界

例如给容器 添加一个add方法的时候调用acquire方法获得一个许可, 如果没有添加就直接释放许可
调用容器的remove(delete)方法的时候返回一个许可.

ConcurrentHashMap

同步类容器
同步容器类在执行每个操作期间都持有一个锁
ConcurrentHashMap的锁是分段锁

闭锁

闭锁是一种同步工具类, 可以延迟线程的进度知道其到达终止状态.
闭锁的作用相当于一扇门

CountDownLatch

CountDownLatch是一种灵活的闭锁提现
闭锁的状态包括一个计数器(初始化)

countDown

countDown方法递减计数器
调用countDown表示有一个时间已经发生了,

await

await方法会等待计数器达到零

下面一个例子演示二元闭锁
起始门(startSate) 和 结束门 (endSate)

    public class CountDownLatchDemo {
    private final CountDownLatch startSate = new CountDownLatch(1);
    private final CountDownLatch endSate;
    private final int N;

    public CountDownLatchDemo(int size) {
        endSate = new CountDownLatch(size);
        N = size;
    }

    public void taskTime(final Runnable runnable) throws InterruptedException {
        for(int i = 0; i < N; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        startSate.await();
                        try {
                            runnable.run();
                        } finally {
                            endSate.countDown();
                        }

                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            thread.start();
        }
        startSate.countDown();
        endSate.await();
    }
}

FutureTask

FutureTask也可以用作闭锁
FutureTask表示的是计算通过Callable来实现
相当于一种可生成结果的Runnable
并且可以处以一下三种状态
1.等待运行(Waiting to run)
2.正在运行(Running)
3.运行完成(Completed) 当FutureTask进入这个状态后,会停止在这个状态

get方法

此方法的行为取决于任务的状态.如果任务已经完成,那么get会立即返回运行结果
否则get将阻塞知道任务进入完成状态 然后返回结果或者抛出异常
FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,
而其内部的规范确保了这种传递过程能安全的发布
注:其中还有很多的异常的问题 看java并发编程实战P81

栅栏(Barrier)

我们已经学习过闭锁, 闭锁是一次性对象,一旦进入终止状态,就不能被重置
栅栏类似于闭锁, 它能阻塞一组线程直到某个时间发生
闭锁用于事件 栅栏用于等待其他线程. 可以用于实现一些协议

CyclicBarrier(周期栅栏)[塞克里克百瑞尔]

可以使一定数量的参与方反复地在栅栏位置汇集
在并行迭代算法中非常有用
这种算法: 通常将一个问题拆分成一系列相互独立的子问题.
当线程到达栅栏位置时将调用await方法, 这个方法将阻塞知道所有线程都达到栅栏位置
如果都到了,那么栅栏将打开, 此时所有线程都被释放.
如果对await的调用超时, 或者await阻塞的线程被中断, 那么栅栏就被认为是打破了
所有阻塞await的调用都将终止并抛出BrokenBarrierException.
如果成功通过栅栏, 那么await将为每个线程返回一个唯一的到达索引号
我们可以利用这些索引选举 产生一个领导线程, 并在下次一迭代中由该领导线程执行一些特殊的工作, CyclicBarrier还可以使你将一个栅栏操作传递给构造函数, 这是一个Runnable, 当成功通过栅栏时会(在一个子任务线程中) 执行它, 但在阻塞线程被释放之前是不能执行的

Exchanger(双方栅栏)

两方(Two-Party)栅栏, 各方在栅栏位置上交换数据. 当两方执行不对称操作时, 会非常有用
例如:
当一个线程向缓冲区写入数据, 而另一个线程从缓冲区读取数据. 这些线程可以使用Exchanger来汇合
并将满的缓冲区与空的缓冲区交换. 当两个线程通过Exchanger交换对象时, 这种交换就把这两个对象安全地发布给另外一方
这里的难点取决于数据交换的时机
一般来说取决于应用程序的响应需求, 但是更简单的方案是, 当缓冲区被填满时, 由填充任务进行交换
当缓冲区为空时, 由清空任务进行交换. 这样会把需要交换次数降至到对滴, 但如果新数据的到达率不可预测, 那么一些数据的处理过程就将延迟. 另一个方法是, 不仅当缓冲被填满时进行交换, 并且当缓冲被填充到一定程度并保持一定时间后,也进行交换

构建高效且可伸缩的结果缓存

下列程序使用HashMap和同步机制来初始化缓存

    public interface Computable<A,V> {
	    V compute(A arg) throws InterruptedException;
	}

计算结果类

    public class ResultFunction implements 
	    Computable<String, BigInteger>{
    @Override
    public BigInteger compute(String arg) throws InterruptedException {
        return new BigInteger(arg);
    }
}

缓存类

    public class Memoizer1<A,V> implements Computable<A,V> {
    
    private final Map<A, V> cache = new HashMap<>();
    private final Computable<A, V> computable;

    public Memoizer1(Computable<A, V> computable) {
        this.computable = computable;
    }

    @Override
    synchronized public V compute(A arg) throws InterruptedException {
        V result = cache.get(arg);
        if(result == null) {
            result = computable.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}

使用HashMap来保存之前计算的结果
compute方法将首先检查需要的结果是否已经在缓存中,
如果存在则返回之前计算的值
否则, 把计算结果缓存在HashMap中, 然后在返回.

**注意: **
HashMap不是线程安全的, 因此要确保两个线程不会同时访问HashMap,
Memoizer1 采用了一种比较保守的方法, 即对整个compute方法进行同步, 这种方法能确定线程的安全性,
但会带来一个明显的可伸缩性的问题, 每次只有一个线程能够执行compute. 如果另一个线程正在计算结果, 那么其他调用compute的线程可能被阻塞很长时间,. 如果有多个线程在排队等待还未计算出的结果
那么compute方法的计算时间可能比没有"记忆"操作的计算时间更长.
下列程序改进了上列的不足

public class Memoizer2<A, V> implements Computable<A, V> {
    private final Map<A, V> cache = new ConcurrentHashMap<>();
    private final Computable<A, V> computable;

    public Memoizer2(Computable<A, V> computable) {
        this.computable = computable;
    }

    @Override
    public V compute(A arg) throws InterruptedException {
        V result = cache.get(arg);
        if(result == null) {
            result = computable.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}

因为ConcurrentHashMap是线程安全的, 因此访问底层的时候就不需要进行同步
但是 这里也仍然存在一些不足
两个线程同事调用compute时存在一个漏洞, 可能会导致计算得到相同的值.
使用缓存的作用是避免相同的数据被计算多次. 对于更通用的缓存机制来说, 这种情况将更为糟糕. 对于只提供单次初始化的对象缓存来说, 这个漏洞就会带来安全风险
Memoizer2所存在的问题
如果某个线程启动了一个开销很大的计算, 而其他线程并不知道这个计算正在进行, 那么狠可能会重复这个计算
我们希望通过某种方法来表示线程X正在计算f(27) 这种情况, 这样当另一个线程查找f(27)时, 它能够知道最高线的方法是等待线程X计算结束, 然后再去查询缓存f(27)的结果是多少
我们已经知道有一个类能基本实现这个功能FutureTask 它表示一个计算的过程,
这个过程可能已经计算完成, 也可能正在运行. 如果结果可用 其get方法将立即返回结果
否则它将会一直阻塞, 知道结果计算出来再将其返回

下列程序
这里加了一层FutureTask这对于调用者是透明的
它先判断任务是否启动 没有启动则创建然后注册到Map中
如果已经启动,那么等待现有的计算结果

public class Memoizer3<A, V> implements Computable<A, V> {
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();
    
    private final Computable<A, V> computable;

    public Memoizer3(Computable<A, V> computable) {
        this.computable = computable;
    }
    
    @Override
    public V compute(final A arg) throws InterruptedException {
        Future<V> future = cache.get(arg);
        if(future == null) {
            Callable<V> callable = new Callable<V>() {
                @Override
                public V call() throws Exception {
                    return computable.compute(arg);
                }
            };
            FutureTask<V> futureTask = new FutureTask<>(callable);
            future = futureTask;
            cache.put(arg, futureTask);
            futureTask.run();
        }
        try {
            return future.get();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

Memoizer3的实现一户是完美的 : 它表现出了非常好的并发性, 如果已经计算出来了那么立即返回
但是它任然有一个小缺陷 那就是可能会同时调用compute 因为if代码块任然是非原子的(nonatomic)的
Memoizer的最终实现

import java.util.Map;
import java.util.concurrent.*;

public class Memoizer<A, V> implements Computable<A ,V> {
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();
    
    private final Computable<A, V> computable;

    public Memoizer(Computable computable) {
        this.computable = computable;
    }

    @Override
    public V compute(A arg) throws InterruptedException {
        while(true) {
            Future<V> future = cache.get(arg);
            if(future == null) {
                Callable<V> callable = new Callable<V>() {
                    @Override
                    public V call() throws Exception {
                        return computable.compute(arg);
                    }
                };
                FutureTask<V> futureTask = new FutureTask<>(callable);
                future = cache.putIfAbsent(arg, futureTask);
                if(future == null) { future = futureTask; futureTask.run();}
            }
            try {
                return future.get();
            } catch (CancellationException e){
                cache.remove(arg, future);
            } catch (ExecutionException e2) {
                throw new RuntimeException(e2);
            }
        }
    }
}

这里使用了ConcurrentMap底层的原子方法 putIfAbsent

Executor框架

java.util.concurrent提供了一种灵活的线程池实现座位Executor
Executor接口

public interface Executor{
    void execute(Runnable command);
}
基于Executor的Web服务器

固定了一个固定长度的线程池, 可以容纳100个线程

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

public class TaskEsecutionWebServer {
    private static final int THREADSSIZE = 100;
    private static final Executor executor = Executors.newFixedThreadPool(THREADSSIZE);

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(80);
        while (true) {
            final Socket connection = serverSocket.accept();
            Runnable task = new Runnable() {
                @Override
                public void run() {
                    handleRequest(connection);
                }
            };
            executor.execute(task);
        }
    }
}

通常Executor的配置是一次性的, 而提交任务的代码会不断地扩散到整个程序 中, 增加了修改的难度
我们可以很容易的修改类似上述行为因此可以编写下列程序
为每个请求启动一个新线程的Executor

import java.util.concurrent.Executor;

public class ThreadPerTaskExecutor implements Executor {
    @Override
    public void execute(Runnable command) {
        new Thread(command).start();
    }
}

同样的还可以编写一个Executor使TaskExecutionWebServer的行为类似于单线程的行为,
即以同步的方式执行每个人物, 然后再返回

import java.util.concurrent.Executor;

public class WithinThreadExecutor implements Executor {
    @Override
    public void execute(Runnable command) {
        command.run();
    }
}

Executor的生命周期

Executor的实现通常会创建线程来执行任务
但JVM只有在所有(非守护)线程终止后才会退出,因为无法正确的关闭Executor,那么JVM无法结束
Executor以异步的方式执行任务, 所以在任何时刻 之前提交任务的状态不是立即可见的
有些可能已经完成 有些可能正在运行 有些可能等待运行
可能采用最平缓的关闭形式(完成已经启动的任务, 拒绝添加新任务)
也有可能最粗暴的关闭(直接关电源, 嘻嘻嘻)
为了解决声明周期问题 Executor扩展了ExecutorService接口

import java.util.Collection;
import java.util.List;


public interface ExecutorService extends Executor {
    void shutdown();
    List<Runnable> shutdownNow();
    boolean isShutdown(); 
    boolean isTerminated();
    boolean awaitTermination(long timeout, TimeUnit unit)
        throws InterruptedException;
    <T> Future<T> submit(Callable<T> task);
    <T> Future<T> submit(Runnable task, T result);
    Future<?> submit(Runnable task);
    <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
        throws InterruptedException;
    <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
                                  long timeout, TimeUnit unit)
        throws InterruptedException;

    <T> T invokeAny(Collection<? extends Callable<T>> tasks)
        throws InterruptedException, ExecutionException;

    <T> T invokeAny(Collection<? extends Callable<T>> tasks,
                    long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

shutdown方法执行平缓的关闭过程
shutdownNow方法执行粗暴的关闭过程, 它尝试取消所有运行中的任务, 并且不再启动队列中未开始执行的任务
它关闭后提交的任务将由拒绝执行处理器来处理
它会抛弃任务, 或者使得execute方法抛出一个未检查的RejectedExecutionException
等待所有任务都完成后,ExecutorService转入终止状态
可以调用awaitTermination等待ExecutorService到达终止状态
或者可以调用isTerminated()来轮询是否到达终止状态
通常调用awaitTermination后会立即调用shutdown

Timer的缺陷&ScheduledThreadPoolExecutor来替代Timer

Timer的缺陷来源于其只有一个线程
例如 一个TimerTask周期任务时间为40ms而另外一个每10ms执行一次
那么由于追赶性在40ms的任务执行后 10ms的会连续执行4次
Timer的另一个问题是, 如果TimerTask抛出了一个未检查的异常, 那么Timer线程则会很糟糕
它不会捕获异常,因此当TimerTask抛出未检查的异常时将终止定时线程.
这种情况Timer也不会恢复线程的执行,而且整个Timer都被取消了. 已经被调度但尚未执行的TimerTask将不会再执行, 新任务也不能被调度. 这样的情况称为线程泄露

替代

如果要构建自己的调度服务, 那么可以使用DelayQueue, 它实现了BlockingQueue, 并为ScheduledThreadPoolExecutor提供调度功能.
**DelayQueue管理着一组Delayed对象.每个Delayed对象都有一个相应的延迟时间 : DelayQueue中, 只有某个元素逾期后, 才能从DelayQueue中执行take操作. 从DelayQueue中返回的对象将根据它们的延迟时间进行排序 **

携带结果的任务Callable与Future

Executor框架使用Runnable作为其基本的任务表示形式. Runnable是一种有很大局限的抽象,虽然run能写入到日志文件或者将结果放入某个共享的数据结构, 但它不能返回一个值或者抛出一个受检查的异常.
Executor执行的任务有4个生命周期阶段 : 创建 提交 开始 完成
因为有些任务可能要执行很长的时间,因此可讷讷个有需求要求结束这些任务
在Executor框架中, 已提交但尚未开始的任务可以取消, 但对于那些已经开始执行的任务, 只有当他们响应中断操作时, 才能取消. 并且取消一个已经完成的任务不会有任何影响

Future表示一个任务的生命周期, 并提供了响应的方法来判断是否已经完成或取消, 已经获取任务的结果和取消任务等.
并且在Future规范中包含的隐含意义是, 任务的声明周期只能前进, 不能后退, 如果某个任务完成后, 它就永远停留在完成状态上

get方法与异常

如果任务已经完成 那么get会立即返回 或者抛出一个Exception
如果任务抛出了异常, 那么get将改异常封装为ExecutionException并重新抛出
如果任务被取消, 那么get将抛出CancellationException
如果get抛出了ExecutionException那么可以通过getCause来获得被封装的初始异常

Callable

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

Future

public interface Future<V> {
    boolean cancel(boolean mayInterruptIfRunning);
    boolean isCancelled();
    boolean isDone();
    V get() throws InterruptedException, ExecutionException;
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

ExecutorService中所有submit方法都将返回一个Future 从而将一个Runnable 或 Callable提交给Executor并得到一个Future
也可以显示的为某个指定的Runnable 和 Callable实例化一个FutureTask
Java 6 开始 ExecutorService实现可以改写AbstractExecutorService中的 newTaskFor方法

proteceted <T> RunnableFuture<T> newTaskFor(Callable<T> task) {
    return new FutureTask<T>(task);
}

CompletionService:Executor 与 BlockingQueue

CompletionService将Executor和BlockingQueue的功能融合在一起
你可以将Callable任务提交给它执行,在任务完成时会封装为Future
ExecutorCompletionService实现了CompletionService, 并将计算部分委托给一个Executor
例如

CompletionService<V> completionService = 
    new ExecutorCompletionService<V>(new ExecutorServiceImpl());

这里的ExecutorServiceImpl就是实现的委托

线程与异常

在ThreadAPI中同样提供了UncaughtExcepitonHanlder
当一个线程由于未捕获的异常而退出时, JVM会把这个时间报告给应用程序提供的UncaughtExcepitonHanlder. 如果没有提供则会默认的将栈追踪信息输出到控制台
令人困惑的是, 只有通过execute提交的任务, 才能将它抛出的异常交给异常处理器
而submit提交的任务, 无论是抛出的未检查还是已经检查, 都将被认为是任务的返回状态一部分
如果一个由submit提交的任务由于抛异常而结束, 那么这个异常则被Future.get封装在ExecutionException中重新抛出

关闭钩子

在正常的关闭中, JVM首先调用所有已经注册的关闭钩子(Shutdown Hook)
关闭钩子是通过Runtime.addShutdownHook注册的但尚未开始的线程
JVM并不能保证关闭钩子的调用顺序
在关闭应用程序线程时, 如果有(守护或非守护)线程任然在运行, 那么这些线程接下来将于关闭进程并发执行. 当所有钩子都执行结束时, 如果runFinalizersOnExit为true 那么JVM启动运行终结器. 然后再停止
jvm不会停止或中断任何在关闭时仍然运行的应用程序线程.
当JVM最终结束时, 这些线程将被强行结束. 如果关闭钩子或终结器没有执行完成, 那么正常关闭线程"挂起" 并且JVM必须被强行关闭. 当被强行关闭时只是关闭JVM 而不会运行关闭钩子

线程池

四种内置线程池

线程池简述
newFixedThreadPool创建一个固定长度的线程池,每当提交一个任务的时候就创建一个线程,直到达到线程池最大数量,这时线程池的规模将不会再变化(如果某个线程遇到了未预期的Exception的时候线程池会补一个线程)
newCachedThreadPool创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制
newSingleThreadExecutor是一个单线程Executor,如果这个线程异常结束,会创建一个线程来替代,它能够保证一招任务在队列中的顺序来串行执行(FIFO)
newScheduledThreadPool创建一个固定长度的线程池,而且以延时或定时的方式执行任务

饱和策略

策略简述
中止(Abort)默认的饱和策略, 该策略将抛出未检查的RejectedException.调用者可以捕获这个异常,然后根据需求编写自己的处理代码.
抛弃(Discard)该策略会抛弃下一个将被执行的任务, 然后尝试重新提交新的任务. 如果工作队列是优先队列, 那么抛弃策略则导致抛弃优先级最高的因此 此策略慎重和优先队列一起使用
调用者运行(Caller)该策略不会抛弃任务, 也不会抛出异常. 而是将某些任务回退给调用者, 从而降低新任务的流量.

也可以使用信号量来限制队列大小和任务到达率

锁对伸缩性的影响

有3种方式可以降低锁的竞争程度
1. 减少锁的持有时间
2. 降低锁的请求频率
3. 使用带有协调机制的独占锁, 这些机制允许更高的并发性

减少锁的持有时间

最好不要对一个方法进行上锁
比如一个类中有一个map的对象
在一个方法中 需要给map添加一个对象
而在这个添加上下的操作都是栈封闭的, 不会对线程安全影响
那么应该只在这个添加加锁. 这样就缩小了锁的获取范围
当然更好的处理方式, 将锁的获取缩小到这个状态的内部
比如把map换成 ConcurrentHashMap 这样就更加的缩小处理范围了

分解锁

如果一个类中有两个状态(两个list对象)
让这个类是线程安全的类, 如果给其方法上加锁就会降低可伸缩性
更好的处理方法是应该给A的List对象做操作就加A的锁, 给B就加B的锁
这样就可以减少锁的竞争

锁分段

把一个竞争激烈的锁分解为两个锁时, 这两个锁可能都存在竞争的激烈
锁分解仍无法给可伸缩性带来极大的提高
在某些情况下, 可以将锁分解技术进一步扩展为对一组对独立对象上的锁进行分解, 这种情况被称为锁分段.
例如在CouncurrentHashMap的实现使用了一个包含16个锁的数组
每个锁保护了所有散列桶的1/16, 其中第N个散列桶由(N mod 16)来保护
如果高访问量的情况下 实现更高的并发性, 还可以进一步增加锁的数量.
锁分段的劣势
通常的情况下, 在执行一个操作时最多值需要获取一个锁, 但是在某些情况下 需要加锁整个容器
例如ConcurrentHashMap需要扩展映射范围时, 复制原值的时候就需要获得分段所有集合的锁
要获取内置锁的一个集合, 能采用的唯一方式是递归


重要!

有一些东西我也并没有写出来 比如ReentrantLock ReentrantReadWriteLock 等等…
但是它们包括Semaphore Condition Future 都是基于AQS实现的. AQS是一个模版方法
如果想深入了解它们的原理 需要学习AQS的源码
下面贴出我对AQS源码的讲解:
https://blog.csdn.net/qq_42011541/article/details/85534378 AQS源码(一)独占
https://blog.csdn.net/qq_42011541/article/details/85538124 AQS源码(二)共享
那分享一下我并发的学习路线吧.
第一本书是JAVA多线程核心技能. 这本书讲的比较浅. 都是教你该怎么用 入门可以了. 几天应该就能看完
第二本书是JAVA并发编程实战 这本是理论上的东西也比较多. 文字有些多 耐心读一读 应该也不太难看.
第三本书是JAVA并发编程艺术 这本书教的东西比较深. 到后面我觉得他讲的不是太多有些东西还是云里雾里,可以去看一下源码


  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值