Effective Java读书笔记——第十章 并发

线程机制允许同时进行多个活动。本章阐述的建议可以帮助编写出清晰、正确、文档组织良好的并发程序。


第66条:同步访问共享的可变数据

关键字synchronized可以保证在同一时刻,只有一个线程可以执行某一个方法,或者某一个代码块。如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态之中。它还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前的所有的修改效果。

在Java中,只有long和double类型的变量无法保证读或写的时候是原子的。换句话说,读取一个非long或double类型的变量,可以保证返回的值是某个线程保存在该变量中的,即使多个线程在没有同步的情况下,也能在并发的修改该变量时,保证该变量的原子性。

有个错误的观点:为了提高性能,在读或者写原子数据的时候,应该避免使用同步。虽然Java保证了线程在读取原子数据的时候,不会看到任意的数值,但是它并不保证一个线程写入的值对于另一个线程将是可见的。为了在线程之间进行可靠的通信,也为了互斥的访问。同步是必要的。

小结一下:即便一个共享变量是原子可读写的,也必须在并发访问的时候对其进行同步,这样才能保证该共享变量在线程之间的可见性。

考虑下面这段没有使用同步的代码段:

public class StopThread {
    private static boolean stop;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                while(!stop) {
                    i++;

                }
            }
        });
        backgroundThread.start();
        TimeUnit().SECOND.sleep(1);
        stop = true;

    }
}

上面代码的初衷是不断轮询工作线程使i自增,当主线程显式地把stop变量置为true时,工作线程自动停止。

但实际运行的效果是(至少在笔者的机器上运行的结果是)工作线程将不停地运行下去。

问题就出在没有同步,所以后台线程无法“看到”主线程对stop变量的改变没有同步,虚拟机将这个代码:

while(!done) {
    ++i;
}

转变成:

if(!done) {
    while(true) {
        ++i;
    }
}

虚拟机的这种优化称之为提升(hoisting),结果造成了活性失败(liveness failure),解决方式是使用synchronized关键字同步stop域:

//这段代码将在1秒后停止
public class StopThread {
    private static boolean stop;
    private static synchronized void requestStop() {
        stop = true;
    }
    private static synchronized boolean stopRequested() {
        return stop;
    }
    public static void main(String[] args) throws InterruptedException {
    Thread backgroundThread = new Thread(new Runnable() {
        public void run() {
            int i = 0;
            while(!stopRequested()) {
                i++;
            }
        }
    });
    backgroundThread.start();
    TimeUnit.SECOND.sleep(1);
    requestStop();


    }
}

需要注意的是:写方法requestStop()和读方法stopRequested()都被同步了,只同步写方法或是读方法是不够的,写方法和读方法必须都同步,否则同步就不会起作用。

需提醒下,即使不加同步,stop变量也是原子的。也就是说,增加同步知识为了它的通信效果,并非为了互斥访问。

第二种解决方式更加简洁,效率也更高,就是使用volatile关键字:

public class stopThread {
    private static volatile boolean stop;
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                while(!stop) {
                    ++i;
                }
            }
        });
        backgroundThread.start();
        TimeUnit.SECOND.sleep(1);
        stop = true;

    }
}

volatile关键字的使用范围有限,它只能保证修饰域的可见性,不能保证其原子性,所以它不能完全代替synchronized关键字。

考虑在下面的方法:

private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
    return nextSerialNumber++;
}

这个方法的目的是确保每次调用都返回不同的值。由于nextSerialNumber 变量是int型的,所以读写该变量时可保证它的原子性,而该变量也用volatile修饰了,这也就保证了可见性,但是即便如此,该代码仍不能正常工作。

原因就在于增量操作符(++)不是原子性的。它做了两步操作:首先读取读取值,然后写回一个新值。如果第2个线程在读取nextSerialNumber变量的时候,第一个线程刚刚读取nextSerialNumber,还没有写回新值,这就会导致两个线程读取了同一个值,导致安全性失败(safety failure)。

一种解决方式是使用synchronized关键字。这样可以保证每次只能有一个线程执行该方法,这样volatile关键字也就可以去掉了。

另一种更好的解决方式是使用AtomicLong类:

private static final AomicLong nextSerialNumber = new AtomicLong();
public static long generateSerialNumber() {
    return nextSerialNumber.getAndIncrement();
}

最后一种解决方式是压根不共享可变的数据,或者共享不可变的数据,即将可变数据限制在单个线程中

总结:多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知(可见性),Java中的double和float操作不是原子性的,所以也要同步,使用java.util.concurrent包中的同步类会很方便,另外自增(++)和自减运算符(–)也不是原子性的,也需要使用concurrent包中的类同步。

第67条:避免过度同步

总结:不要在同步区域内部调用外来方法。更一般地讲,要限制同步代码块的工作量。


第68条:executor和task优先于线程

JDK1.5中,在Java.util.concurrent包中加入了Executor框架。通过他可以很方便地创建工作队列:

ExecutorService executor = Executors.newSingleThreadExecutor();

提交一个runnable方法:

executor.execute(runnable);

终止队列:

executor.shutdown();

如果想让不止一个线程来处理来自这个队列的请求,只要调用一个不同的静态工厂,即线程池。

当编写的是一个小程序时,使用Executors.newCachedThreadPool是很好的选择,这是一个带有缓存的线程池。

如果是大负载的服务器可以考虑使用Executors.neeFixedThreadPool,它提供了一个包含固定线程数目的线程池。

Executor框架中还有一个可以代替Java.util.Timer的类:ScheduleThreadPoolExecutor。timer只用一个线程执行任务,若该线程抛出异常,timer会停止。而被调度的线程池executor支持多个线程,并且优雅地从抛出未受检查异常的任务中恢复。


第69条:并发工具优先于wait和notify

Java.util.concurrent包中提供了三种辅助编写并发程序的类:Executor框架、并发集合(Concurrent Collection )、同步器(Synchronizer)。第一种在第68条中介绍了,本条将介绍后两种。

并发集合为标准的集合接口(如List、Queue、Map)提供了高性能的并发实现。为了提高并发性,这些实现在内部自己管理同步。所以,并发集合中不可能排除并发活动,将它锁定并没什么用。

ConcurrentMap扩展了Map接口。并添加了几个方法。包括putIfAbsent(key , value);当键没有映射时会替它插入一个映射,并返回与键关联的前一个值。如果没有这样的值,则返回null。

下面的方法模拟了String.intern方法:

(有关String.intern方法,可以参见这篇文章String中intern的方法

private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<String, String>();
public static String intern(String s) {
    String previousValue = map.putIfAbsent(s,s);
    return previousValue == null ? s : previousValue;
}

优化上面的代码

public static String intern(String s) {
    String result = map.get(s);
    if(result == null) {
        result = map.putUfAbsent(s,s);
        if(result == null) {
            result = s;
        }
    }
    return result;
}

如需使用同步的Map,那么优先顺序为:

ConcurrentHashMap > Collections.synchronizedMap > HashTable


BlockQueue是一个扩展了Queue接口的具有阻塞功能(一直等待到可以成功执行为止)的集合队列:它的take方法从队列中删除并返回头元素,如果队列为空,就等待,将其用作工作队列或乘坐生产着消费者队列。一个或多个生产者线程在工作队列中添加工作项目,并且当工作项目可用时,一个或多个消费者线程从工作队列中去除队列并处理工作项目。

同步器是一些使线程能够等待另一个线程的对象,允许它们协调工作。最常用的同步器是CountDownLatch(倒计数锁存器)和Semaphore(信号量)。(还有CyclicBarrier和Exchanger,但这两个不常用。)

倒计数锁存器(Countdown Latch)是一次性的障碍允许一个或者多个线程等待一个或者多个其他线程来做某些事情倒计数寄存器的唯一构造器带有一个int类型的参数,它表示允许在所有等待的线程被处理之前,必须在锁存器上调用countDown方法的次数。

有关CountdownLatch的介绍,可以参考这篇文章:Java之CountDownLatch使用

考虑下面的方法:

//记录一个并发的动作的执行时间
//@Executor executor 执行并发任务的线程池
//@int concurrency 并发量
//@Runnable action 执行动作的runnable
public static long time(Executor executor, int concurrency,final Runnable action) throws InterruptedException {
    //等待所有的工作线程都已经准备就绪
    final CountDownLatch ready = new CountDownLatch(concurrency);
    //开始执行
    final CountDownLatch start = new CountDownLatch(1);
    //等待所有的工作线程都执行完毕
    final CountDownLatch done = new CountDownLatch(concurrency);
    for (int i = 0; i < concurrency; ++i) {
        executor.execute(new Runnable() {
            public void run() {
                //当concurrency个工作线程都执行完毕,ready.await()方法返回true。timer线程继续执行
                ready.countDown();
                try {
                    //所有的工作线程在此阻塞,直到timer线程记录完定时的起始时间,并调用start.countDown();表示解除所有工作线程的阻塞,start.await()返回true,所有工作线程继续并发执行
                    start.await();
                    //执行动作
                    action.run();

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    //每一个工作线程执行完毕后(包括被中断),调用一次done.countDown();当调用了concurrency次后,表示所有工作线程执行完毕,timer线程的  done.await();将返回true,timer线程将解除阻塞,继续执行
                    done.countDown();

                }

            }

        });
        //timer线程等待所有的工作线程都准备就绪
        //阻塞
        ready.await();
        //运行至此,表示所有工作线程均已准备就绪,记录一个起始时间
        long startNanos = System.nanoTime();
        //解除所有工作线程的阻塞,所有工作线程开始并发执行任务
        start.countDown();
        //timer线程等待所有的工作线程都执行完毕,time线程阻塞
        done.await();
        //所有工作线程执行完毕,记录当前时间,减去起始时间,返回值表示所有并发线程的执行时间(从第一个工作线程开始执行到最后一个工作线程执行完毕所用时间)
        return System.nanoTime() - startNanos;
    }


}

需要注意的是,executor中的并发任务数,也就是for循环的次数如果少于CountDownLatch的ready对象的初始化的参数,也就是说,ready.contDown()的执行次数如果少于ready初始化的参数指定的值,那就会导致ready.await()一直阻塞,timer线程无法继续运行,这就是线程饥饿死锁(Thread starvation deadlock)。最后需要注意的是,使用System.nanoTime()的定时方式比System.currentTimeMills()更加精确,且不受系统实时时钟的调整所影响。

虽然java.util.concurrent包中的同步器可以完成wait()和notify()的功能,但一些需要维护的老的代码中仍可能出现wait()和notify(),所以,最后说一些针对wait()和notify()的优化建议:

  • 确保wait()方法是在while循环中调用;

  • 一般应优先使用notifyAll();

  • 尽量不要在被public修饰的对象的同步方法中包含wait方法,因为这有可能会导致另一个线程意外或恶意调用notify唤醒当前线程,而此时还未达到可以唤醒当前线程的条件。

  • 如果处于等待的线程都在等待同一个条件,而每次只有一个线程可以从这个条件中被唤醒,那么就应该选择notify而不是notifyAll 。


第70条:线程安全的文档化

首先说一个错误 的观点,那就是“只要是加了synchronized关键字的方法或者代码块就一定是线程安全的,而没有加这个关键字的代码就不是线程安全的”。这种观点认为“线程安全要么全有要么全无”,事实上这是错误的。因为线程安全包含了几种级别

  • 不可变的(immutable):类的实例不可变(不可变类),一定线程安全,如String、Long、BigInteger等。

  • 无条件的线程安全(unconditionally thread-safe):该类的实例是可变的,到那时这个类有足够的的内部同步,所以,它的实例可以被并发使用,无需任何外部同步,如Random和ConcurrentHashMap。

  • 有条件的线程安全(conditionally thread-safe):某些方法需要为了安全并发而外部同步,其余与无条件的线程安全一致。如Collection.synchronized返回的集合,它们的迭代器(iterator)需要同步。

  • 非线程安全(not thread-safe):该类是实例可变的,如需安全地并发使用,必须外部手动同步。如HashMap和HashSet。

  • 线程对立的(thread-hostile):即便所有的方法都被外部同步保卫,这个类仍不能安全的被多个线程并发使用。这种情况的类很少。

    简而言之,每个类都应该认真编写线程安全注解,清楚地在文档中说明它的线程安全属性。synchronized关键字与这个文档毫无关系。有条件的线程安全类必须在文档中指明“哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁。”如果正在编写的是无条件的线程安全类,就应该考虑使用私有的锁对象来代替同步方法,这样可以防止客户端程序和子类的不同步干扰:

private final Object lock = new Object();

public void foo() {
    synchronized(lock) {
        ...
    }
}

因为这个私有锁对象不能被这个类的客户端程序访问,所以它们不可能妨碍对象的同步。


第71条:慎用延迟初始化

延迟是初始化(lazy initialization)是延迟到需要域的值时才将它们初始化的这个种行为。如果不需要这个值,那么这个域将永远不会被初始化。

延迟初始化是一把双刃剑,它降低了初始化类或者创建实例的开销,却增加了访问被延迟初始化域的开销。它实际上降低了性能。

多线程情况下,如果两个或多个线程共享一个延迟初始化的域。采用某种形式的同步是很重要的。

下面初始化了一个正常的私有域:

private final FieldType = computeFieldValue();

如果延迟初始化该域,就需要使用synchronized关键字:

private FieldType field;

synchronized FieldType getField() {
    if(field = null) {
        field = computeFieldValue();
    }
    return field;
}

如果需要对静态域进行懒加载:

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

getField()方法并没有同步,因为该方法在执行到FieldHolder.sField;时,FieldHolder类将被加载,而同一个类只会被加载一次,所以无需同步。

如果处于性能的考虑而需要对实例域进行延迟初始化,应该考虑使用双检锁机制(double-check idiom),这种模式只在第一次初始化域的时候可能会有同步开销,而且需要把域声明成volatile的(保证线程之间域的可见性):

private volatile FieldType field;
FieldType getField() {
    FieldType result = field;
    if(result == null) {
        synchronized(this) {
            result = null;
            field = result = computeFieldValue();
        }
    }
    return result;
}

双检锁也是单例模式的一种实现方式。

如果如果某个域可以被多次初始化,而且是除long和double的基本类型,那么就可使用单检查模式,并且无需使用volatile关键字:

private int number;
private int getNumber() {
    // 0是基本变量的默认值
    if(number == 0) {
        number = computeNumber();
    }
    return number;
}

如果初始化的的域可以被多次初始化,而且是引用该类型,或long、double类型,那么需要嘉盛volatile关键字,但无需同步:

private volatile FieldType field;
private FieldType getField() {
    FieldType result = field;
    if(result == null) {
        field = result = computeFieldValue();
    }
    return field;
}

总结:大多数域应被正常初始化。而非延迟初始化。如果为了达到某种目的(如提高性能或破坏有害的初始化循环)而必须延迟初始化。

对于实例域,应当使用双检锁模式;对于静态域,应使用懒加载模式。对于可以接受重复初始化的实例域,可以考虑使用单检查模式。

第72条:不要依赖线程调度器

当有多个线程并发执行时,由线程调度器来控制哪那些线程应该执行、应该执行多久。这种程序时不可移植的,因为不同的机器和平台所进行的调度策略很不一样。

要使多线程应用程序变得可移植,应当确保可运行线程的平均数量不明显多于处理器的数量

线程不应该一直处于忙等(busy-wait)的状态。即反复地等待一个共享对象的锁被释放。考虑这个例子:

public class SlowCountDownLatch {
    private int count;
    public SlowCountDownLatch (int count) {
        if(count < 0) {
            throw new IllegalArgumentException(count + " < 0");
        }
        this.count = count;
    }
    public void await() {
        while(true) {
            synchronized(this) {
                if(count == 0) {
                    return;
                }
            }
        }
    }
    public synchronized void countDown() {
        if(count != 0) {
            count--;
        }
    }
}

当把1000个线程放到线程池时,主线程中的await()方法将进入死循环状态,每次都要进入同步代码块中判断count值是否为0,直到countDown方法执行了(至少)1000次以后,await()方法才能退出死循环,继续执行接下来的代码。这使效率大大降低。

另外使用Thread.yield()方法并不能使其他线程有效获得更多的CPU执行时间片段,也不要用设置优先级的方式来改善某个线程的执行时间。而应该使用TimeUnit.SECOND.sleep(1);来代替Thread.yield();,并且只在测试并发程序时使用。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值