并发包
并发是面试吹逼必备的技能,自从有了现代操作系统之后,线程或则进程的同步、竞争、资源争夺我们都需要考虑。
Java5.0之后一个很重要的特性就是增加了并发包java.util.concurrent.*,在说具体的实现类或接口之前,我们需要
先简要说下Java程序的内存模型、volatile变量以及抽象队列同步器AQS,这些都是并发包众多实现的基础。
Java内存模型(又称JMM)
JMM描述了线程内存与主存之间的通讯关系。定义了线程内的内存改变将怎么样传递到其它线程的规则,同时
也定义了线程内存与主存进行同步的细节,也描述了哪些操作属于原子操作及操作间的顺序。这里注意到,Java是
与平台无关的语言,所以在不同的物理机器上面JVM的实现是不同的,我们仅仅是描述了要实现这样的功能以及进行规范。
代码顺序规则:
一个线程内的每个动作发生在同一个线程内在代码顺序上在其后的所有动作之前(happens-before原则)
volatile变量规则:
对一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入(可见性,读到最新值)
传递性:
如果A happens-before B ,B happen-before C,那么A happens-before C
这里解释一下,为什么有happen before原则,在同一个线程内 代码顺序之前的为什么要规定先执行,难道
不是本来就是先执行的??
是这样的,我们的处理器执行指令的时候一般采用流水线的方法,所以编译器编译的时候会进行指令重排,为了
提高处理器指向效率,像那些没有体系结构里面数据相关控制相关的指令可能执行顺序会被改变。这样的改变在并发编程
中可能造成错误,所以这种场景我们使用happen before原则禁止指令重排。
volatile
当我们声明共享变量为volatile后,对这个变量的读/写将会很特别。理解volatile特性的一个方法是:
把对volatile变量的单个读/写,看成是使用同一个监视器锁对这些单个读写操作做了同步。
监视器锁的happens-before规则保证释放监视器和获取监视器的两个线程之间的内存可见性,这意味对一个
volatile变量的读,总是能看到对这个volatile变量最后的写入。
简而言之,volatile修饰的变量自身具有下列特性:
第一、可见性。对一个volatile变量的读,总是能看到对这个volatile变量最后的写入。
第二、原子性。对任意单个volatile变量的读/写具有原子性,当类似于volatile++这种复合操作不具有原子性。
volatile写的内存语义如下:
当写一个volatile变量时,JMM规定线程应该把工作内存中的共享变量马上刷新到主内存。
volatile读的内存语义如下:
当读一个volatile变量时,JMM规定把线程工作内存中对应的共享变量置为无效,线程接下来将从主内存
中读取共享变量。
这里声明一点JMM规定的线程工作内存不是线程栈什么的,它是JVM对物理机上cache等物理器件的抽象。
下面对volatile写和volatile读的内存语义做个总结:
一.线程A写一个volatile变量,实质上是线程A向接下来将要读做个volatile变量的某个线程发出了其对共享变量所在修改的消息
二. 线程B读一个volatile变量,实质上是线程B接受了之前某个线程发出的消息
三.线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
锁释放-获取与volatile的读写具有相同的内存语义,
锁释放的内存语义如下:
当线程释放锁时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存。
锁获取的内存语义如下:
当线程获取锁时,JMM会把该线程对应的工作内存置为无效,从而使得被监视器保护的临界区代码必须要从主内存中读取共享变量。
下面对锁释放和锁获取的内存语义做个总结:
线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
class VolatileExample {
int x = 0;
volatile int b = 0;
private void write() {
x = 5;
b = 1;
}
private void read() {
int dummy = b;
while (x != 5) {
}
}
public static void main(String[] args) throws Exception {
final VolatileExample example = new VolatileExample();
Thread thread1 = new Thread(new Runnable() {
public void run() {
example.write();
}
});
Thread thread2 = new Thread(new Runnable() {
public void run() {
example.read();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
}
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型仍然会允许volatile变量与普通变量之间重排序。JSR-133则增强了volatile的内存语义:严格限制编译器(在编译期)和处理器(在运行期)对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义。限制重排序是通过内存屏障实现的,具体可见JMM的描述。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替监视器锁,请一定谨慎。
AQS 抽象同步队列
AQS使用一个整形的volatile修饰的变量(命名为state)来维护同步状态,这是接下来实现大部分同步需求的基础。
提供了一个先进先出的队列,可以用来构建锁或者其他相关同步装置的基础框架。使用AQS的方法是继承,子类通过
继承同步器并实现它的方法来管理其状态,管理的方式就是通过类似acquire和release的方式来操纵状态。然而在多线程环境中
对状态的操纵必须确保原子性,因此子类对于状态的把握,需要使用这个同步器提供的以下三个方法对状态进行操作:
java.util.concurrent.locks.AbstractQueuedSynchronizer.getState()
java.util.concurrent.locks.AbstractQueuedSynchronizer.setState(int)
java.util.concurrent.locks.AbstractQueuedSynchronizer.compareAndSetState(int, int)
AQS子类被推荐定义为自定义同步装置的内部类,AQS同步器自身没有实现任何同步接口,
它仅仅是定义了若干acquire之类的方法来供使用,该同步器既可以作为排他模式也可以作为共享模式,
当他被实现为排他模式时,其它线程对其的获取就会被阻止,而共享模式多个线程获取都可以成功。
同步器是实现锁的关键,利用同步器将锁的语义实现,然后在锁的实现中聚合同步器。可以这样理解:
锁的API是面向使用者的,它定义了与锁交互的公共行为,而每个锁的功能通过这些行为来完成的;但实现是
依托同步器的子类来完成的;同步器面向的是线程访问和资源控制,它定义了线程对资源是否能获取以及线程
的排队操作。锁和同步器很好的隔离了两者所需关注的领域,严格意义上讲,锁只是依靠同步器实现的一种同步
设施,其他同步设施也可以依靠同步器来实现。
AQS同步器的开始提到了起其实现依赖于FIFO队列,队列中的元素Node就是保存着线程引用和线程状态的容器,
每个线程对同步器的访问,都可以看做是队列中的一个节点。
对应一个独占锁的获取和释放有如下伪代码可以表示:
获取一个排他锁
while(获取锁) {
if (获取到) {
退出while循环
} else {
if(当前线程没有入队列) {
那么入队列
}
阻塞当前线程
}
}
释放一个排他锁
if (释放成功) {
2 删除头结点
3 激活原头结点的后继节点
4 }
示例:
下面通过一个排他锁的例子来深入理解一下同步器的工作原理,而只有掌握同步器的工作原理才能更加深入了解
其它的并发组件。
排他锁的实现,一次只能一个线程获取到锁:
/**
我们实现的互斥锁
*/
public class Mutex implements Lock, java.io.Serializable {
// 内部类,实现AQS同步器的方法
private static class Sync extends AbstractQueuedSynchronizer {
// getState是否有进程处于临界区,有返回1,无返回0
protected boolean isHeldExclusively() {
return getState() == 1;
}
// 获取锁,使用到CSA算法 V=0 E=0 N=1
public boolean tryAcquire(int acquires) {
assert acquires == 1; // Otherwise unused
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
// 释放锁,将状态设置为0
protected boolean tryRelease(int releases) {
assert releases == 1; // Otherwise unused
if (getState() == 0) throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0);
return true;
}
// 返回一个Condition,每个condition都包含了一个condition队列
Condition newCondition() { return new ConditionObject(); }
}
// 仅需要将操作代理到Sync上即可
private final Sync sync = new Sync();
public void lock() { sync.acquire(1); }
public boolean tryLock() { return sync.tryAcquire(1); }
public void unlock() { sync.release(1); }
public Condition newCondition() { return sync.newCondition(); }
public boolean isLocked() { return sync.isHeldExclusively(); }
public boolean hasQueuedThreads() { return sync.hasQueuedThreads(); }
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
}
可以看到Mutex将Lock接口均代理给了同步器的实现。使用方将Mutex构造处理后,调用lock获取锁,
调用unlock将锁释放。
获取锁,acqure(int arg)的主要逻辑包括:
注意到,这里使用了设计模式的模板方法
1.尝试获取 (调用tryAcquire更改状态),使用CAS算法保证原子性;
在tryAcquire方法中使用了同步器提供的对state操作的方法,厉害compareAndSwap保证了只有一个线程
能够对状态进行成功修改,而没有成功修改的线程将进入sync队列排队。
进入队列的每个线程都对应着一个节点node,从而形成了一个双向队列,类似CLH队列,这样做的目的是
线程间的通信会被限制在较小的规模(也就是两个节点左右)
3.再次尝试获取,如果没有获取到那么将当前线程从线程调度器摘下,进入等待状态。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
1.尝试释放状态;
tryRelease能够保证原子化的将状态设置回去,当然需要CAS算法来保证。如果释放成功之后,就会进入
后续节点的唤醒过程。
2.唤醒当前节点的后继节点所包含的线程
通过LockSupport的unpark方法将休眠中的线程唤醒,让其继续acquire状态。
回顾整个资源的获取和释放过程:
在获取时,维护了一个sync队列,每个节点都对应一个线程在进行自旋,而依据就是自己是否是首节点
的后继并且能够获取资源;
释放时,需要将资源还回去,然后通知一下后继节点并将其唤醒。
这里要注意,队列的维护是依靠消费者来完成的,也就是说在满足了自旋退出的条件时的一刻,这个节点就会被设置成为首节点。
队列里的节点线程的禁用和唤醒是通过LockSupport的park()及unpark(),调用的unsafe、底层也是native的实现。
查看类图,咱们很清晰的看到并发包提供的所都继承Lock接口,线程的禁用和唤醒调用的是LockSupprot封装的
系统原生方法。
共享模式和上面的独占模式有所区别,分别调用acquireShared(int arg)和releaseShared(int arg)获取共享模式的状态
以文件的查看为例,如果一个程序对齐进行读取操作,那么这一时刻,对这个文件的写操作就会被阻塞,相反,这一时刻
另一个程序对齐进行同样的读操作是可以进行的,如果一个程序对齐进行写操作,那么所有的读与写操作在这一时刻
就会被阻塞,直到这个程序完成写操作。
以读写场景为例,描述通过共享和独占的访问模式,如下图所示:
上图中,红色代表被阻塞,绿色代表可以通过。
在上述对一个排他锁的实现进行分析后,AQS 锁我们是很熟悉了。接下来我们设计一个可以运行两个线程
进入临界区的同步工具,当然了如上所说这种也是基于AQS实现的。超过限制的其它线程进入阻塞状态
对应这个需求,在操作系统PV中我们把信号量的初值设置为2。想不到吧,Java也是,之前没有学的时候以为是
什么高级的东西,呵呵更OS里面的一毛一样,我计算机学科综合都能考120,找个工作都找不到,就是不认识这些
被包装过的名词。我们在Java中,可以利用同步器完成这样的设定,定义一个初始状态,为2,一个线程进行获取那么减1
一个线程释放那么加1,状态正确的范围为在[0,1,2]三个之间,当在0时,代表有新的线程对资源进行获取时只能进入
阻塞状态(注意在任何时候进行状态变更的时候均需要以CAS作为原子性保障)。由于资源的数量多于1个,同时可以有
两个线程占有资源,因此需要实现tryAcquireShared和tryReleaseShared方法。
public class TwinsLock implements Lock {
//锁必须基于AQS实现,这个内部类是AQS的子类 实现了AQS的一些方法
private static final class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = -7889272986162341211L;
Sync(int count) {
if (count <= 0) {
throw new IllegalArgumentException("count must large than zero.");
}
setState(count);
}
public int tryAcquireShared(int reduceCount) {
for (;;) {
int current = getState();
int newCount = current - reduceCount;
if (newCount < 0 || compareAndSetState(current, newCount)) {
return newCount;
}
}
}
public boolean tryReleaseShared(int returnCount) {
for (;;) {
int current = getState();
int newCount = current + returnCount;
if (compareAndSetState(current, newCount)) {
return true;
}
}
}
}
//这下面是lock接口需要实现的类,锁只是代理同步器的方法
private final Sync sync = new Sync(2); //state初始化为2
public void lock() {
sync.acquireShared(1);
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public boolean tryLock() {
return sync.tryAcquireShared(1) >= 0;
}
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(time));
}
public void unlock() {
sync.releaseShared(1);
}
public Condition newCondition() {
return null;
}
public class TwinsLockTest {
@Test
public void test() {
final Lock lock = new TwinsLock();
class Worker extends Thread {
public void run() {
while (true) {
lock.lock();
try {
Thread.sleep(1000L);
System.out.println(Thread.currentThread());
Thread.sleep(1000L);
} catch (Exception ex) {
} finally {
lock.unlock();
}
}
}
}
for (int i = 0; i < 10; i++) {
Worker w = new Worker();
w.start();
}
new Thread() {
public void run() {
while (true) {
try {
Thread.sleep(200L);
System.out.println();
} catch (Exception ex) {
}
}
}
}.start();
try {
Thread.sleep(20000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
上述测试用例的逻辑主要包括:
1.打印工作线程
Worker在两次睡眠之间打印自身线程的名字
2.分割线程
不停的打印换行,能让worker的输出看起来更加直观。
该测试的结果是在某一时刻,仅有两个线程能获取到锁,并完成打印,而表象是打印的内容成对出现
利用CAS是不会进行阻塞的,只会一个返回成功,一个返回失败,保证了一致性。
CAS操作同时具有volatile读和volatile写的内存语义。
接下来我们就要说一下并发包的具体实现类了
2.1 ConcurrentHashMap
ConcurrentHashMap是线程安全的哈希表的实现,默认构造同样有initialCapacity和loadFactor属性
不过多了一个concurrencyLevel属性,三属性默认值分别为16,0.75,以及16。其内部使用了锁分段技术,
维持着这锁Segment的数组,在Segment数组中又存放着Entity[]数组,内部hash算法将数据较为均匀分布在不同
锁中。
put操作:并没有在此方法中加入synchronized,首先对key.hashCode进行hash操作,得到key的hash值。
hash操作的算法和map也不同,根据此hash值计算并获取其对应数组中的Segment对象
继承自(ReentrantLock),接着调用Segment对象的put方法来完成当前操作。
ConcurrentHashMap基于concurrency划分出了多个Segment来对K-V进行存储,从而避免每次put操作
都得锁住整个数组。在默认情况下,最佳情况下可允许16个线程并发无阻塞的操作集合对象,尽可能地
减少并发时的阻塞现象。
get(key)
首先根据指定算法对key.hashCode值进行hash操作,基于其值找到对应的Segment对象,调用其get()方法完成
当前操作。而segement的get操作首先通过hash值和对象数组大小的减一的值进行按位与操作来获取
数组上对应位置的HashEntry。在这个步骤中,可能会因为对象数组大小的改变,以及数组上对应位置的HashEntry
产生不一致性,那么ConcurrentHashMap是如何保证的?
对象数组大小的改变只有在put操作时有可能发生,对应HashEntry对象数组对应的变量是volatile类型的,因此
可以保证对hashEntry对象数组大小发生改变,读操作可看到最新的对象数组大小。
在获取到了HashEntry对象后,怎么能保证它及其next属性构成的链表上的对象不会改变呢?这点ConcurrentHashMap
采用了一个简单的方式,即HashEntry对象中的hash key next属性都是final的,这就意味着没办法插入一个HashEntry
对象到基于next属性构成的链表中间或末尾,这样就保证当前获取到HashEntry对象后,其基于next属性构建的链表
是不会发生变化的。
ConcurrentHashMap默认情况下降数据分为16个段进行存储,并且16个段分别持有各自不同的锁segment,
锁仅用于put和remove等改变集合对象的操作,基于volatile及HashEntry链表的不变形实现了读取的不加锁。
这些方式使得ConcurrentHashMap能够保持极好的并发支持,尤其是对于读远比插入和删除频繁的MAP
而它采用的这些方法也是对于Java内存模型,并发机制深刻掌握的体现。
2.2 ReentrantLock
在并发包的开始部分介绍了volatile特性以及AQS同步器,而这两部分正是可重入锁实现的基础。
通过上面介绍AQS的源码和我们自己的两个锁,可以知道是以volatile修饰的int类型的state值,来判断
线程是执行还是在syn队列中等到。
可重入锁的实现不仅仅可以替代synchronized关键字,而且能够提供超过关键字本身的多种功能。
这里提到一个获取锁的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定被先满足,那么
这个锁是公平的,反之,是不公平的,类似FIFO是公平的。可重入锁可以控制这个锁是否是公平的。
对应公平和非公平的定义可以通过对同步器的扩展加以实现的,也就是在tryAcquire的实现上做了语义的控制。
2.3 Condition
Condition是并发包中提供的一个接口,典型的实现有可重入锁,可重入锁提供了一个newCondition
的方法,以便用户在同一个锁的情况下可以根据不同的情况执行等待或唤醒动作。典型的是用法可以参考
ArrayBlockingQueue的实现,下面来看可重入锁中newCondition的实现
ReentrantLock.newCondition()
创建一个AbstractQueuedSynchronizer的内部类ConditionObject的对象实例。
ReentrantLock.newCondition().await()
将当前线程加入此condition的等待队列中,并将线程置为等待状态。
ReentrantLock.newCondition().signal()
从此condition的等待队列中获取一个等待节点,并将节点上的线程唤醒,如果要唤醒全部等待节点的线程,则调用signalAll方法。
2.4 CopyOnWriteArrayList
CopyOnWriteArrayList是一个线程安全,并且在读操作时无锁的ArrayList,其具体实现方法入下。
CopyOnWriteArrayLsit()
和ArrayList不同,此步的做法是为创建一个大小为0的数组。
add(E)
add方法并没有加上synchronized关键字,它通过可重入锁来保证线程安全。此处和ArrayList
的不同是每次都很创建一个新的Object数组,此数组的大小为当前数组大小加1,将之前数组中的
内容复制到新的数组中,并将新增加的对象放入数组末尾,最后做引用切换到新创建的数组对象
赋值给全局的数组对象。
remove(E)
和add方法一样,此方法也通过ReentrantLock来保证其线程安全,但它和ArrayList删除元素采用的方式
并不一样。
此方法和ArrayList除了锁不同外,最大的不同在于其赋值过程中没有调用系统的ArrayCopy来完成,
所以性能会比较差。
get(int)
此方法非常简单,直接获取当前数组对应位置的元素,这种方法是没有加锁保护的,因此可能会出现
读到dirty data的情况。但相对而言,性能会比较高,对应写少读多而且dirty data影响不大的场景而言是不错
的选择。
iterator()
调用iterator方法后创建一个新的COWIterator对象实例,并保存了一个当前数组的快照,在调用next遍历时则仅对此快照数组进行遍历,因此遍历此list时不会抛出ConcurrentModificatiedException。
与ArrayList的性能对比,在读多写少的并发场景中,较之ArrayList是更好的选择,单线程以及多线程下增加元素及删除元素的性能不比ArrayList好
2.5CopyOnWriteArraySet
这个数据结构是基于copyonwriteArrayList实现,其唯一的不同是在add时调用的是
copyOnwriteArrayList的addIfAbent方法,保证看无重复元素,但在add时要每次继续数组的遍历,因此
性能会比上面的更低。
2.6ArrayBlockingQueue
2.7 ThreadPoolExecutor
与每次需要时都创建线程相比,线程池可以降低创建线程的开销,在线程执行结束后进行回收操作,提高
对线程的复用。在Java中主要使用到的线程池是ThreadPoolExecutor,此外还有定时的线程池
ScheduledThreadPoolExecutor
Java里面线程池的顶级接口是Executor,但是严格意义将Executor并不是一个线程池,而只是一个执行线程
的工具。真正的线程池接口是ExecutoService。
比较重要的几个类:
ExecutorService 真正的线程池接口
ScheduledExecutorService 和Time/TimeTask类似,解决需要任务重复执行的问题
ThreadPoolExecutor ExecutorService的默认实现
SchedulesThreadPoolExecutor 继承ThreadPoolExecutor的ScheduledExecutorService接口实现,周期性任务调度的类实现
要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,很有可能
配置的线程池不是较优的,因此在Executors类里面提供了一些静态工厂,生成一些常用的线程池。
1.newSingleThreadExecutor
创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。
如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务执行顺序按照
任务的提交顺序执行。
2.newFixedThreadPool
创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池
的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程,
3.newCachedThreadPool
创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲线程
(60S不执行任务的),当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对
线程池的大小做限制,线程池的大小完全依赖于操作系统或则说JVM能够创建的最大线程大小。
3newScheduledThreadPool
创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。
最后需要注意使用,一个线程的线程池和固定线程的线程池超过处理的线程放在队列中,
当工作线程较多时,会引起过多内存被占用,而后两者返回的线程池是没有线程上线的,所以需要
使用时担心,创建过多的线程容易引起服务器宕机。任务队列里面把内存占满了,新的线程启动不能就绪
死机了。
除了上面的静态工程提供的方法,使用ThreadPoolExcutor自定义线程池,具有使用时需根据系统以及
JVM配置适当的参数,下面是一个例子。
1 int corePoolSize = Runtime.getRuntime().availableProcessors();
2 threadsPool = new ThreadPoolExecutor(corePoolSize, corePoolSize, 10l, TimeUnit.SECONDS,
3 new LinkedBlockingQueue<Runnable>(2000));
获取宿主机的处理器数,第一个参数是线程标准时的数字,第二个参数可以设计为最大可扩展到的数组,第三和第四个
参数表明超过10S线程没有用,超过第一个参数线将会被回收,第五个参数 是任务队列。
2.8Future和FutureTask
Future是一个接口,FutureTask是一个具体实现类。这里先通过两个场景看看其处理方式及优点。
场景1,
现在调用一个方法从远程获取一些计算结果,假设有这样一个方法:
HashMap data = getDataFromRemote();
如果是最传统的同步方式的使用,我们将一直等待getDataFromRemote()返回,然后才能继续后面
的工作。这个函数是从远程获取数据的计算结果,如果需要的时间很长,并且后面的那部分代码与这些
数据没有关系的话,阻塞在这里等待结果会比较浪费时间。如何改进呢?
能够想到的办法就是调用函数后马上返回,如何继续向下执行。等到需要用到数据的时候再来用或则
等待这个数据。具体实现有两种方式:一个是用Futur,另一个是使用回调。
Future的用法
Future<HashMap> future=getDataForRemote2();
//做一些跟这个数据没有关系的事情。
HashMap data=future.get()
可以看到,我们调用方式返回一个Future对象,然后接着进行做个跟这些数据没有关系的事情,
后面通过future.gte来获取真正的返回值。也就是,调用了getdataFromRemote后,就已经启动了对远程计算结果的
获取,同时增加的线程还在继续处理,直到需要时再获取数据,来看一下getdataFromRemote2的实现:
privete Future<HashMap> getDataFromRemote2(){
return threadPool.submit(new Callable<HashMap>(){
public HashMap call() throws Exception{
return getDataFromRemote();
}
});
}
可以看到,在getDataFromRemote中还是使用了getDataFoRemote来完成具体操作,并且用到了线程池:
把任务加入到线程池中,把Future对象返回获取。我们调用了getDataFromRemote2的线程返回继续下面的执行,
而背后的另外的线程在执行原创调用以及等待的工作。get方法也可以设置超时时间参数,而不是取不到一直在等
。
场景2,
K-V的形式存储连接,若key存在则获取,若key不存在,则创建新连接并存储。
传统的方式使用hashMap来存储并判断key是否存在而实现连接的管理。而这在高并发的时候会出现
多次创建连接的现象。那么新的处理方式又是怎么样的?
通过ConcurrentHashMap和FutureTask实现高并发情况的正确性,ConcurrentHash的分段锁存储满足数据
的安全性又不影响性能,FutureTask的run方法调用Sync innerRun方法只会执行Runnable的run方法一次,即便
是高并发的情况下。
2.9并发容量
在JDK中,有一些线程不安全的容器,也有一些线程安全的容器。并发容器是线程安全的容器的一种,但是
并发容器强调的是容器的并发性,也就是不仅追求线程安全,还考虑并发性,提升容器在并发环境下的性能。
加锁互斥的方式确实能够方便地完成线程安全,不过代价是降低了并发性,或则说是串行了。而在并发容器的思路
是尽量不要锁,比较有代表性的是以CopyOnWrite和Concurrent开头的几个容器。
CopyOnWrite容器的思路是在更改容器的时候,把容器写一份进行修改,也称作写时复制,保证正在读的线程不收影响,
这种方式在读多写少的场景中会非常好,因为实质上在写的时候重建了容器。而以Concurrent开头的容器的据图实现方式
则完全不同,总体来说是尽量保证读不加锁,并且修改不影响读,所以达到比使用读写锁更高的并发性能。比如上面
说的ConcurrentHashMap,其它的并发容器实现,可以直接分析JDK中的源码。