Effective Java——并发

                        目录
六十六、同步访问共享的可变数据
六十七、避免过度同步
六十八、executor和task优先于线程
六十九、并发工具优先于wait和notify
七十、线程安全性的文档化
七十一、慎用延迟初始化
七十二、不要依赖于线程调度器
七十三、避免使用线程组


六十六、同步访问共享的可变数据

        在Java中很多时候都是通过synchronized关键字来实现共享对象之间的同步的。事实上,对象同步并不仅限于当多个线程操作同一可变对象时,仍然能够保证该共享对象的状态始终保持一致。与此同时,他还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改效果。

        Java的语言规范保证了读写一个变量是原子的,除非这个变量的类型为long或double。换句话说,读取一个非long或double类型的变量,可以保证返回的值是某个线程保存在该变量中的,即时多个线程在没有同步的情况下并发地修改这个变量也是如此。然而需要特别指出的是,这样的做法是非常危险的。即便这样做不会带来数据同步修改的问题,但是他会导致另外一个更为隐匿的错误发生。见如下代码:

public class StopThread {
    private static boolean stopRequested = false;
    public static void main(String[] args) throw InterruptedException {
        Thread bgThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                while (!stopRequested)
                    i++;
            }
        });
        bgThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

        对于上面的代码片段,有些人会认为在主函数sleep一秒后,工作者线程的循环状态标志(stopRequested)就会被修改,从而致使工作者线程正常退出。然而事实却并非如此,这个程序永远不会停止。因为Java的规范中并没有保证在非同步状态下,一个线程修改的变量,在另一个线程中就会立即可见。事实上,这也是Java针对内存模型进行优化的一个技巧。事实上,虚拟机把run方法优化成下面的代码:

public void run() {
    int i = 0;
    if (!stopRequested) {
        while (true)
            i++;
    }
}

        这种优化被称为提升,正是HotSpot Server VM的工作。结果是个活性失败:程序无法前进。要解决这个问题并不难,只需在读取和写入stopRequested的时候加入synchronized关键字即可,见如下代码:

public class StopThread {
    private static boolean stopRequested = false;
    private static synchronized void requestStop() {
        stopRequested = true;
    }
    private static synchronized boolean stopRequested() {
        return stopRequested;
    }
    public static void main(String[] args) throw InterruptedException {
        Thread bgThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                while (!stopRequested())
                    i++;
            }
        });
        bgThread.start();
        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }
}

        在上面的修改代码中,读写该变量的函数均被加以同步。事实上,Java中还提供了另外一种方式用于处理该类问题,即volatile关键字。该单词的直译为“易变的”,引申到这里就是告诉cpu该变量是容易被改变的变量,不能每次都从当前线程的内存模型中获取该变量的值,而是必须从主存中获取,这种做法所带来的唯一负面影响就是效率的折损,但是相比于synchronized关键字,其效率优势还是非常明显的。和第一个代码片段相比,只需要在stopRequested域变量声明之前加上volatile关键字。然而需要说明的是,使用volatile的时候要小心,该关键字并不能完全取代synchronized同步方式,见如下代码:

public class Test {
    private static volatile int nextID = 0;
    public static int generateNextID() {
        return nextID++;
    }
}

        generateNextID方法的用意为每次都给调用者生成不同的ID值,遗憾的是,最终结果并不是我们期望的那样,当多个线程调用该方法时,极有可能出现重复的ID值。这是因为++运算符并不是原子操作,而是由两个指令构成,首先是读取该值,加一之后再重新赋值。由此可见,这两个指令之间的时间窗口极有可能造成数据的不一致。如果要修复该问题,我们可以使用JDK(1.5 later)中java.util.concurrent.atomic包提供的AtomicLong类,使用该类性能要明显好于synchronized的同步方式,见如下修复后的代码:

public class Test {
    private static final AtomicLong nextID = new AtomicLong();
    public static long generateNextID() {
        return nextID.getAndIncrement();
    }
}

        简而言之,当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。如果只要线程之间的交互通信,而不需要互斥,volatile修饰符就是一种可以接受的同步形式。


六十七、避免过度同步

        过度同步所导致的最明显问题就是性能下降,特别是在如今的多核时代,再有就是可能引发的死锁和一系列不确定性的问题。当同步函数或同步代码块内调用了外来方法,如可被子类覆盖的方法,或外部类的接口方法等。由于这些方法的行为存在一定的未知性,如果在同步块内调用了类似的方法,将极有可能给当前的同步带来未知的破坏性。下面这个类是在第16条中实现的:

public class ObservableSet<E> extends ForwardingSet<E> {
    public ObservableSet(Set<E> set) {
        super(set);
    }
    private final List<SetObserver<E>> observers = new ArrayList<SetObserver<E>>();
    public void addObserver(SetObserver<E> observer) {
        synchronized(observers) {
            observers.add(observer);
        }
    }
    public boolean removeObserver(SetObserver<E> observer) {
        synchronized(observers) {
            return observers.remover(observer);
        }
    }
    private void notifyElementAdded(E element) {
        synchronized(observers) {
            for (SetObserver<E> observer : observers)
                observer.added(this,element);
        }
    }
    @Override public boolean add(E element) {
        boolean added = super.add(element);
        if (added)
            notifyElementAdded(element);
        return added;
    }
    @Override public boolean addAll(Collection<? extends E> c) {
        boolean result = false;
        for (E element : c)
            result |= add(element);
        return result;
    }
}

        下面的代码片段是回调接口和测试调用:

public interface SetObserver<E> {
    void added(ObservableSet<E> set,E element);
}

public static void main(String[] args) {
    ObservableSet<Integer> set = 
        new ObservableSet<Integer>(new HashSet<Integer>());
    set.addObserver(new SetObserver<Integer>() {
        public void added(ObservableSet<Integer> s, Integer e) {
            System.out.println(e);
        }
    });
    for (int i = 0; i < 100; i++)
        set.add(i);
}

        对于这个测试用例,他完全没有问题,可以保证得到正确的输出,即打印出0-99的数字。现在我们换一个观察者接口的实现方式: 

set.addObserver(new SetObserver<Integer>() {
    public void added(ObservableSet<Integer> s,Integer e) {
        System.out.println(e);
        if (e == 23)
            s.removeObserver(this);
    }
});

        对于以上代码,当执行s.removeObserver(this)的时候,将会抛出ConcurrentModificationException异常,因为在notifyElementAdded方法中正在遍历该集合。对于该段代码,我只能说我们是幸运的,错误被及时抛出并迅速定位,这是因为我们的调用是在同一个线程内完成的,而Java中synchronized关键字构成的锁是可重入的,或者说是可递归的,即在同一个线程内可多次调用且不会被阻塞。如果恰恰相反,我们的冲突调用来自于多个线程,那么将会形成死锁。在多线程的应用程序中,死锁是一种比较难以重现和定位的错误。为了解决上述问题,我们需要做的一是将调用外部代码的部分移出同步代码块,再有就是针对该遍历,我们需要提前copy出来一份,并基于该对象进行遍历,从而避免了上面的并发访问冲突,如:

private void notifyElementAdded(E element) {
    List<SetObserver<E>> snapshot = null;
    synchronized(observers) {
        snapshot = new ArrayList<SetObserver<E>>(observers);
    }
    for (SetObserver<E> Observer : snapshot)
        Observer.added(this,element);
}

        减少不必要的代码同步还可以大大提高程序的并发执行效率,一个非常明显的例子就是StringBuffer,该类在JDK的早期版本中即以出现,是数据操作同步类,即时我们是以单线程方式调用该类的方法,也不得不承受块同步带来的额外开销。Java在1.5中提供了非同步版本的StringBuilder类,这样在单线程应用中可以消除因同步而带来的额外开销,对于多线程程序,可以继续选择StringBuffer,或者在自己认为需要同步的代码部分加同步块。

        简而言之,为了避免死锁和数据破坏,千万不要从同步区域内部调用外来方法。更为一般地讲,要尽量限制同步区域内部的工作量。


六十八、executor和task优先于线程

        在Java 1.5 中提供了java.util.concurrent包,在这个包中包含了Executor Framework框架,这是一个很灵活的基于接口的任务执行工具。该框架提供了非常方便的调用方式和强大的功能,如:

ExecutorService executor = Executors.newSingleThreadExecutor(); //创建一个单线程执行器对象。
executor.execute(runnable); //提交一个待执行的任务。
executor.shutdown();  //使执行器优雅的终止。

        事实上,Executors对象还提供了更多的工厂方法,如适用于小型服务器的Executors.newCachedThreadPool()工厂方法,该方法创建的执行器实现类对于小型服务器来说还是比较有优势的,因为在其内部实现中并没有提供任务队列,而是直接将任务提交给当前可用的线程,如果此时没有可用的线程了,则创建一个新线程来执行该任务。因此在任务数量较多的大型服务器上,由于该机制创建了大量的工作者线程,这将会导致系统的整体运行效率下降。对于该种情况,Executors提供了另外一个工厂方法Executors.newFixedThreadPool(),该方法创建的执行器实现类的内部提供了任务队列,用于任务缓冲。另外,相比于java.util.Timer,该框架也提供了一个更为高效的执行器实现类,通过工厂方法 Executors.ScheduledThreadPool()可以创建该类。它提供了更多的内部执行线程,这样在执行耗时任务是,其定时精度要优于Timer类。


六十九、并发工具优先于wait和notify

        java.util.concurrent中更高级的工具分成三类:Executor Framework、并发集合(Concurrent Collection)以及同步器(Synchronizer)。相比于java.util中提供的集合类,java.util.concurrent中提供的并发集合就有更好的并发性,其性能通常数倍于普通集合,如ConcurrentHashMap等。换句话说,除非有极其特殊的原因存在,否则在并发的情况下,一定要优先选择ConcurrentHashMap,而不是Collections.syschronizedMap或者Hashtable。java.util.concurrent包中还提供了阻塞队列,该队列极大的简化了生产者线程和消费者线程模型的编码工作。

        对于同步器,concurrent包中给出了四种主要的同步器对象:CountDownLatch、Semaphore、CyclicBarrier和Exchanger。这里前两种比较常用。在该条目中我们只是简单介绍一个CountDownLatch的优势,该类允许一个或者多个线程等待一个或者多个线程来做某些事情。CountDownLatch的唯一构造函数带有一个int类型的参数 ,这个int参数是指允许所有在等待的线程被处理之前,必须在锁存器上调用countDown方法的次数。

        简而言之,直接使用wait和notify就像用“并发汇编语言”进行编程一样,而java.util.concurrent则提供了更高级的语言。如果你在维护使用wait和notify的代码,务必确保始终是利用标准的模式从while循环内部调用wait。一般情况下,你应该优先使用notifyAll,而不是使用notify。


七十、线程安全性的文档化

        下面的列表概括了线程安全性的几种级别,但是并没有涵盖所有的可能,只是一些常用的情形:
        1.不可变的。这个类的实例是不变的。所以不需要外部的同步。如:String和BigInteger。
        2.无条件的线程安全。这个类的实例是可变的,但是这个类有着足够的内部同步,所以其实例可以被并发使用,无需任何外部同步。如:Random和ConcurrentHashMap。
        3.有条件的线程安全。除了有些方法为进行安全的并发使用而需要外部同步之外,这种线程安全级别与无条件的线程安全相同。
        4.非线程安全。这个类的实例是可变的,为了并发使用它们,客户必须利用自己选择的外部同步包围每个方法调用。如:ArrayList和HashMap。
        5.线程对立的。这个类不能安全地被多个线程并发使用,即使所有的方法调用都被外部同步包围。线程对立的根源通常在于,没有同步地修改静态数据。没有人会有意编写一个线程对立的类;这种类是米有考虑到并发性而产生的后果。


七十一、慎用延迟初始化

        延迟初始化作为一种性能优化的技巧,它要求类的域成员在第一次访问时才执行必要的初始化动作,而不是在类构造的时候完成该域字段的初始化。和大多数优化一样,对于延迟初始化,最好的建议"除非绝对必要,否则就不要这么做"。延迟初始化如同一把双刃剑,它确实降低了实例对象创建的开销,却增加了访问被延迟初始化的域的开销,这一点在多线程访问该域时表现的更为明显。见如下代码:

public class TestClass {
    private FieldType field;
    synchronized FieldType getField() {
        if (field == null)
            field = computeFieldValue();
        return field;
    }
}

        从上面的代码可以看出,在每次访问该域字段时,均需要承担同步的开销。如果在真实的应用中,在多线程环境下,我们确实需要为一个实例化开销很大的对象实行延迟初始化,又该如何做呢?该条目提供了3种技巧:
        1. 对于静态域字段,可以考虑使用延迟初始化Holder class模式:

public class TestClass {
    private static class FieldHolder {
        static final FieldType field = computeFieldValue();
    }
    static FieldType getField() {
        return FieldHolder.field;
    }
}

        当getField()方法第一次被调用时,它第一次读取FieldHolder.field,导致FieldHolder类得到初始化。这种模式的魅力在于,getField方法没有被同步,并且只执行一个域访问,因此延迟初始化实际上并没有增加任何访问成本。
        2. 对于实例域字段,可使用双重检查模式:

public class TestClass {
    private volatile FieldType f;
    FieldType getField() {
        FieldType result = f;
        if (result == null) {
            synchronized(this) {
                result = f;
                if (result == null)
                    f = result = computeFieldValue();
            }
        }
        return result;
    }
}

        注意在上面的代码中,首先将域字段f声明为volatile变量,其语义在之前的条目中已经给出解释,这里将不再赘述。再者就是在进入同步块之前,先针对该字段进行验证,如果不是null,即已经初始化,就直接返回该域字段,从而避免了不必要的同步开销。然而需要明确的是,在同步块内部的判断极其重要,因为在第一次判断之后和进入同步代码块之前存在一个时间窗口,而这一窗口则很有可能造成不同步的错误发生,因此第二次验证才是决定性的。
        在该示例代码中,使用局部变量result代替volatile的域字段,可以避免在后面的访问中每次都从主存中获取数据,从而提高函数的运行性能。事实上,这只是一种代码优化的技巧而已。针对该技巧,最后需要补充的是,在很多并发程序中,对某一状态的测试,也可以使用该技巧。
        3. 对于可以接受重复初始化实例域字段,可使用单重检查模式:

public class TestClass {
    private volatile FieldType f;
    FieldType getField() {
        FieldType result = f;
        if (result == null)
            f = result = computeFieldValue();
        return result;
    }
}

        简而言之,大多数的域应该正常地进行初始化,而不是延迟初始化。如果为了达到性能目标,或者为了破坏有害的初始化循环,而必须延迟初始化一个域,就可以使用相应的延迟初始化方法。


七十二、不要依赖于线程调度器

        当有多个线程可以运行时,由线程调度器决定哪些线程将会运行,以及运行多长时间。任何一个合理的操作系统在做出这样的决定时,都会努力做到公正,但是所采用的策略却大相径庭。因此,编写良好的程序不应该依赖于这种策略的细节。任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能都是不可移植的。

        简而言之,不要让应用程序的正确性依赖于线程调度器。线程优先级可以用来提高一个已经能够正常工作的程序的服务质量,但永远不应该用来“修正”一个原本不能工作的程序。


七十三、避免使用线程组

        除了线程、锁和监视器之外,线程系统还提供了一个基本的抽象,即线程组。线程组的初衷是作为一种隔离applet的机制,当然是出于安全的考虑。但线程组并没有提供所提及的任何安全功能。线程组已经过时了,其API也有很多缺陷。

        总而言之,线程组并没有提供太多有用的功能,而且它们提供的许多功能都是由缺陷的。建议忽略掉线程组,当它们根本不存在一样。如果你设计的一个类需要处理线程的逻辑组,应该使用线程池executor。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值