Java并发编程实战(一)

线程的优势

  • 降低程序的开发维护成本
  • 提升资源利用率以及系统吞吐率
  • 提高用户界面的响应灵敏度
  • 发挥多处理其的强大能力
  • 异步事件的简化处理

风险

  • 安全性问题
  • 活跃性问题
  • 性能问题

线程安全

线程安全指的是在多线程环境下,程序的执行能在保证可靠和正确性。当多个线程访问某个类时,这个类始终都能表现出正确的行为。那么就称这个类是线程安全的。

原子性

竞态条件和复合操作

当某个计算的正确性取决于多个线程交替执行时序是,那么就会发生竞态条件。如下是典型的延迟初始化中的竞态条件,当多个线程同时访问getInstance方法时,存在一种情况是两个线程同时满足a==null 条件,则在实际上会创建两个Instance,使程序偏离正确性。

class Instance{
    private static Instance a;
    static Instance getInstance(){
        if(a == null) a = new Instance();
        return a;
    }
}

以上代码中 a = new Instance()就是一个复合操作,大体上包含了三个步骤:
1. new Instance();
2. 将a的引用指向创建的对象
3. 将a的引用的值同步到主存
相反的,如果这个操作在线程调度间是不可再分割的,那么这个操作就是原子操作。

加锁机制

内置锁

Java提供了一种内置锁机制来支持原子性:synchronized block。包括两个部分:1、作为锁的对象引用。2、作为由这个锁保护的代码块。
以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用的对象。静态的synchronized方法以Class对象作为锁。

重入

当某个线程持有锁时,其它线程是不能够执行需要获取该锁的代码块的,但是同一个线程中的某个需要该锁的同步代码块是可以执行的。这就是重入的含义。
重入意味着获取锁的操作的力度是“线程”,而不是“调用”。
重入的实现方法是为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,锁被认为是没有任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程推出同步代码块时,计数器将会相应地递减。当计数值为0时,这个锁将被释放。

对象的共享

可见性

一下程序可能产生几种结果,1、输出42。2、输出0。3、无法终止。因为代码中没有使用足够的同步机制,因此无法保证主线程写入的ready值和number值对于线程来说是可见的。

    public class NoVisibility{
        private static boolean ready;
        private static int number;
        public static void main(String[] args){
            new Thread(){
                public void run(){
                    while(!ready)Thread.yield();
                    System.out.println(number);
                }
            }.start();
            number = 40;
            ready = true;
        }
    }

加锁与可见性

内置锁可以用户确保某个线程以一种可预测的方式来查看另一个线程的执行结果。Java还提供了一种稍弱的同步机制,即volatile变量,用来确保将白亮的更新操作通知到其它线程。当把变量声明为volatile类型后,编译器与运行时(Runtime)都会注意到这个变量是共享的,因此不会将该变量上的操作与其它内存操作一起重排序。volatile变量不会被缓存到寄存器或者对其它处理器不可见的地方,因此读取volatile类型的变量时总会返回最新写入的值。

虽然volatile变量很方便,但也存在一些局限性。volatile变量通常用做某个操作完成、发生中断或者状态的标志。但是在使用时要非常小心,例如在执行count++操作时,volatile不能保证原子性(原子变量提供了“读-写-改”的原子操作)

发布与逸出

发布(publish)一个对象是指,使对象能够在当前作用于之外的代码中使用。例如,将一个指向改对象的引用保存到其它代码可以访问的地方,或者在某个非私有的方法中返回该引用,或者将引用传递到其它类的方法中。发布内部状态可能会破坏封装性,并使得程序难以位置不变性条件。当某个不该发布的对象被发布时,这种情况就被称为逸出(Escape)。

//发布了knowSecrets,并间接发布了Set集合中的Secret对象
public static Set<Secret> knowSecrets;
public void initialize(){
    knownSecrets = new HashSet<Secret>();
}

发布一个内部类实例时,也隐含的发布了外部类,因为这个内部类的实例包含了对ThisEscape实例的隐含引用。

public class ThisEscape{
    public ThisEscape(EventSource source){
        source.registerListener(
            new EventListener(){
                public void onEvent(Event e){
                    doSomething(e);
                }
        });
    }
}

安全的对象构造过程:在ThisEscape中给出了逸出的一个特殊示例,即this引用在构造函数中逸出。当内部的EventListener实例发布时,在外部封装的ThisEscape实例也逸出了。当且仅当对象的构造函数返回时,对象才处于可预测的和一致性的状态。因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果this引用在构造过程中逸出,那么这种对象就被认为是不正确的构造。

不要在构造过程中是this引用逸出

常见的错误:1、在构造函数中启动一个线程,注意是启动,在构造函数中创建一个线程并没有错,但最好不要立即启动。2、在构造函数中调用一个可改写的实例方法是,同样会导致this引用在构造过程中逸出。

如果想要在构造函数中注册一个事件监听器或启动线程,那个可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程,如下所示:

public class SafeListener{
    private final EventListener listener;
    private SafeListener(){
        listener = new EventListener(){
            public void onEvent(Event e){
                doSomething(e);
            }
        };
    }
    public static SafeListener newInstance(EventSource source){
        SafeListener safe = new SafeListener();//已经构造完成
        source.registerListener(safe.listener);//正确发布,其它线程在构造完成之前无法访问this
        return sace;
    }
}

线程封闭

如果尽在单线程内方位数据,就不需要同步。这个技术称为线程封闭,它是实现线程安全性最简单的方式之一。在Swing中大量使用了线程封闭技术。Swing的可视化组件和数据模型对象都不是线程安全的,Swing通过将他们封闭到Swing的事件分发线程中来实现线程安全性。

Ad-hoc线程封闭

Ad-hoc线程封闭式指,维护线程封闭性的职责完全有程序实现来承担。Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。由于Ad-hoc线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该是用更强的线程封闭技术,例如栈封闭或ThreadLocal类

栈封闭

栈封闭式线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能使得代码更容易维持不变形条件那样,同步变量也能使对象更易于封闭在线程中。局部变量的股友属性之一就是封闭在执行线程中。他们位于执行线程的栈中,其它线程无法访问这个栈。

ThreadLocal类

维持线程封闭性的一种更规范的方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

不变性

满足同步需求的另一种方法是使用不可变对象(Immutable Object)

基础构建模块

同步容器类

同步容器类包括Vector和Hashtable,同步的封装器是由Collections.synchronizedXxx等工厂方法创建的。这些类实现线程安全的方式是:将他们的状态封装起来,并对每个共有方法都进行同步,使得每次只有一个线程能访问容器的状态。

同步容器类的问题

同步容器类都是线程安全的,但是在某些情况可能需要额外的客户端加锁来保护符合操作。容器上常见的复合操作包括:
1. 迭代(反复访问元素,直到便利完容器中所有元素)
2. 跳转(根据指定顺序找到当前元素的下一个元素)
3. 条件运算,例如若没有则添加
在同步容器类中,这些符合操作在没有客户端加锁的情况下仍然是线程安全的,但其他线程并发的修改容器时,他们可能会表现意料之外的行为。

下面给出了在Vector中定义的两个方法:getLast和deleteLast,他们都会执行“先检查在运行”操作。每个方法首先获得数组的大小,然后通过结果来获取或删除最后一个元素。

public static Object getLast(Vector list){
    int lastIndex = list.size()-1;
    return list.get(lastIndex);
}
public static void deleteLast(Vector list){
    int lastIndex = list.size()-1;
    list.remove(lastIndex);
}

这些方法其实在多线程环境下是有问题的。如果线程A在包含10个元素的Vector上调用getLast,同时线程B在同一个Vector上调用deleteLast,这些操作的交替执行下,getLast将抛出ArrayIndexOutOfBoundsException异常。在调用size与调用getLast这两个操作之间,Vector变小了,因此在调用size时得到的索引值将不再有效。

由于同步类要遵守同步策略,即支持客户端加锁,因此可能会创建一些新的操作,只要我们知道应该使用哪一个锁,那么这些操作就与容器的其它一些操作一样都是原子操作。同步容器类通过其自身的锁来保护它的每一个方法。通过获得容器类的锁,我们可以使getLast和deleteLast成为原子操作,并确保Vector的大小在调用size和get之间不会发生变化

public static Object getLast(Vector list){
    synchronized(list){
        int lastIndex = list.size()-1;
        return list.get(lastIndex);
    }
}

public static Object deleteLast(Vector list){
    synchronized(list){
        int lastIndex = list.size()-1;
        list.remove(lastIndex);
    }
}

另外,在调用size和相应的get之间,Vector的长度可能会发生变化,这种风险在对Vector中的元素进行迭代时仍然会出现

for(int i=0;i<vector.size();i++){
    doSomething(vector.get(i));
}

这种迭代操作的正确性要依赖于运气,即在调用size和get之间没有线程会修改Vector。虽然在上述代码可能抛出ArrayIndexOutOfBoundsException异常,但这并不意味着Vector就不是线程安全的。Vector的状态仍然是有效的,而抛出的异常也与其规范保持一致。我们可以通过加锁,但这样会导致其它线程在迭代的过程中无法访问Vector,因此降低了并发性。

synchronized(vector){
    for(int i=0;i<vector.size();i++){
        doSomething(vector.get(i));
    }
}

迭代器与ConcurrentModificationException

虽然Vector是一个古老的容器类。然而许多现代的容器类也并没有消除复合操作中的问题。无论在直接迭代还是在Java5。0引入的for-each循环语法中,对容器类进行迭代的标准方式都是使用Iterator。然而如果有其他线程并发地修改容器,那么及时使用迭代器也无法避免在迭代期间对容器加锁。在设计同步容器类的迭代器是并没有考虑到并发修改的问题,并且他们表现出的行为是“及时失败”的。这以为这,当他们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。

并发容器

Java5.0提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现他们的线程安全性。这种方式的代价是严重降低并发性,当多个线程竞争容器的锁是,吞吐量将严重减低。

另一方面,并发容器是针对多个线程并发访问设计的。在Java5.0中增加了ConcurrentHashMap,用来替代同步且基于散列的Map,以及CopyOnWriteArrayList,用于在便利操纵为主要操作的情况下代替同步的List。

ConcurrentHashMap

与HashMap一样,ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁。在这种机制下,任意数量的线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map,可以实现更高的吞吐量,而在单线程环境中只损失非常小的性能。

ConcurrentHashMap与其它并发容器一起增强了同步容器类:他们提供的迭代器不会抛出ConcurrentModificationException,因此不需要再迭代过程中对容器加锁。但是ConcurrentHashMap返回的迭代器具弱一致性,而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会便利已有的元素,并可以(当时不保证)在迭代器被构造后将修改操作反映给容器。

尽管有这些改进,但仍然有一些需要权衡的匀速。对于一些需要在整个Map上进行计算的方法,例如size和isEmpty,这些方法的语义被略微减弱了以反映容器的并发特性。由于size返回的结果在计算时可能已近过期,他实际上只是一个狙击值,因此允许size返回一个近似值而不是一个精确值。虽然这看上去有些令人不安,但事实上size和isEmpty这样的方法在并值而不是一个精确值。虽然这看上去有些令人不安,但事实上size和isEmpty这样的方法在并发环境下的用处很小,因为他们的返回值总在不断变化。因此,这些操作的需求被弱化了,以换取对其他更重要操作的性能优化,包括get、put、containsKey和remove等。

在ConcurrentHashMap中没有实现对Map加锁以提供独占访问。在HashTable和synchronizedMap中,获得Map的锁能防止其他线程访问这个Map。在一些不常见的情况中需要这种功能,例如通过原子方式添加一些映射,或者对Map迭代若干此并在此期间保持元素顺序相同,然而,总体来说这种权衡哈市合理的,因为并发容器的内容会持续变化。

与Hshtable和synchronizedMap相比,ConcurrentHashMap有着更多的优势以及更少的劣势,因此在大多数情况下,用ConcurrentHashMap来替代同步Map能进一步提高代码的可伸缩性。只有当应用程序需要加锁Map已进行独占访问时,才应该放弃使用ConcurrentHashMap。

额外的原子Map操作

由于ConcurrentHaspMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作,例如“若没有则添加”、“若相等则移除”、和“若相等则替换”等,都已经实现为原子操作并且在ConcurrentMap的接口中声明。

 public interface ConcurrentMap<K,V> extends Map<K,V>
     V putIfAbsent(K key,V value);
     boolean remove(K key,V value);
     boolean replace(K key,V oldValue,V newValue);
     V replace(K key,V newValue);

CopyOnWriteArrayList

CopyOnWriteArrayList用户替代同步List,在某些情况下他提供了更好的并发性能,别切在迭代期间不需要对容器进行加锁或复制。

阻塞队列和生产者-消费者模式

阻塞方法与中断方法

线程可能会阻塞或暂停执行,原因有多重:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep方法中醒来,或是等待另一个线程计算结果。当线程阻塞时,它通常被挂起,并处于某种阻塞状态(Blocked、WAITING或TIMED_WAITING)

同步工具类

闭锁

闭锁是一种同步工具类,可以延迟线程的进度知道其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能够通过,当到达结束状态时,这扇门会打开并允许所有线程通过。闭锁可以用来确保某些活动直到其它活动都完成后才继续执行,例如:

  • 确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须现在这个闭锁上等待。
  • 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都都有一个相关的二元闭锁。当启动服务S时,将首先在S依赖的其它服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁S,这样其他依赖S的服务才能继续执行。
  • 等待某个操作的所有参与者都就绪在继续执行。

    CountDownLatch是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或者多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个整数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数器到大岭,这表示所有需要等待的时间都已经发生。

public class TestHarness{
    public long timeTasks(int nThreads,final Runnable task){
        final CountDownLatch startGate = new CountDownLatch(1);//起始门设置为1,等待所有线程创建完毕
        final CountDownLatch endGate = new CountDownLatch(nThreads)//结束门设置为n,等待所有线程执行完毕
        for(int i = 0; i<nThreads;i++){
            Thread t = new Thread(){
                public void run(){
                    startGate.await();
                    try{
                        task.run();
                    }finally{
                        endGate.countDown();
                    }
                }
            };
            t.start();
        }
        long start = System.nanoTime();
        startGate.countDown();
        endGate.await();
        long end = System.nanoTime();
        return end-start;
    }
}

通过上述方式,我们可以保证所有线程同时启动,并且等待最后一个线程执行完成。

FutureTask

FutureTask也可以用作闭锁。(FutureTask实现了Future语义),表示一种抽象的可以生成结果的计算。FutureTask表示的计算时通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于一下3种状态;等待运行,正在运行和运行完成。

信号量

阅读更多
个人分类: java
想对作者说点什么? 我来说一句

Java并发编程

2017年12月28日 40.81MB 下载

没有更多推荐了,返回首页

不良信息举报

Java并发编程实战(一)

最多只允许输入30个字

加入CSDN,享受更精准的内容推荐,与500万程序员共同成长!
关闭
关闭