Effective java笔记(九),并发

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

JVM对不大于32位的基本类型的操作都是原子操作,所以读取一个非long或double类型的变量,可以保证返回的值是某个线程保存在该变量中的,但它并不能保证一个线程写入的值对于另一个线程是可见的。因此在读或写原子数据时,使用线程同步是有必须要的,否则将时线程间数据不一致。

public class ThreadTest {
    private static boolean stopRequested; //原子操作

    public static void main(String[] args) throws Exception{
        Thread thread = new Thread (new Runnable() {
            public void run() {
                int i=0;
                while(!stopRequested) {
                    i++;
                }
            }
        });
        thread.start();
        Thread.sleep(1000);
        stopRequested = true;
    }
}

上面这段代码中,由于boolean域的读和写操作都是原子操作,你可能期待这个程序运行大约1秒钟后,主线程将stopRequested设置为true,致使thread线程的循环终止。但事实上这个程序永远也不会停止:thread线程永远在循环。问题在于,thread的线程不能「看到」主线程对stopRequested所做的改变。

修正这个问题的一种方式是同步访问stopRequest域:

public class ThreadTest {
    private static volatile boolean stopRequested;

    private static synchronized void requestStop() {
        stopRequested = true;
    }
    private static synchronized boolean stopRequested() {
        return stopRequested;
    }

    public static void main(String[] args) throws Exception{
        Thread thread = new Thread (new Runnable() {
            public void run() {
                int i=0;
                while(!stopRequested()) {
                    i++;
                }
            }
        });
        thread.start();
        Thread.sleep(1000);
        requestStop();
    }
}

注意:写方法(requestStop)和读方法(stopRequested)都必须被同步,否则不起作用。(好像有一个就够了???)

第二种方式:使用volatile

private static volatile boolean stopRequested;

volatile保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对其他线程来说是立即可见的。


使用volatile时必须小心,考虑下面的代码:

private static volatile int nextNumber = 0;
public static int generateNumber() {
    return nextNumber++;
}

这个方法的目的是确保每次调用都返回不同的值。虽然变量nextNumber是可原子访问的域,但它依然不能正常工作。问题在于,增量操作符「+」不是原子的。nextNumber++执行两项操作:首先它读取值,然后写回一个新值。若第二个线程在第一个线程读取旧值和写回新值期间读取这个域,第二个线程就会与第一个线程获得同样的值。这就是「安全性失败」。

修正的办法是在方法的声明中增加synchronized修饰符或使用类AtomicLong,如:

private static final AtomicLong nextNumber = new AtomicLong();

public static long generateNumber() {
    return nextNumber.getAndIncrement();
}

总之,当多个线程共享可变数据的时候,每个读或写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的「活性失败」和「安全性失败」。

67、避免过度同步

依据不同的情况,过度同步可能会导致性能降低、死锁、甚至不确定的行为。

为了避免活性失败和安全性失败,在一个被同步的方法或者代码块中,永远不要调用外来方法。即不要调用为了覆盖而设计的方法,或者是由客户端以函数的形式提供的方法(如,观察者模式)。如下面这个类,该类允许客户端在将元素添加到集合中时预定通知(观察者模式)。

import java.util.*;  
  
public class ObservableSet{  
    private final Set s;
    private final List>  observers =   
        new ArrayList>();  
  
    public ObservableSet(Set s) {  
        this.s = s; 
    }  
    
    //预定通知    
    public void addObserver(SetObserver observer){  
        synchronized (observers) {  
            observers.add(observer);  
        }  
    }  
    
    //取消通知  
    public boolean removeObserver(SetObserver observer){  
        synchronized (observers) {  
            return observers.remove(observer);  
        }  
    }  
    
    //通知所有观察者 
    private void notifyElementAdded(E element){  
        synchronized (observers) {  
            for(SetObserver observer : observers){  
                observer.added(this, element);  
            }  
        }  
    }  
  
    public boolean add(E element) {  
        boolean added = s.add(element);  
        if (added) {  
            notifyElementAdded(element);  
        }  
        return added;  
    }  

    public static void main(String[] args) {
        ObservableSet set = new ObservableSet(new HashSet());  
        set.addObserver(new SetObserver() {  
              
            @Override  
            public void added(ObservableSet set, Integer element) {  
                System.out.print(element+" "); 
                if(element == 23)
                    set.removeObserver(this); 
            }  
        });  
          
        for(int i = 0; i < 100; i++){  
            set.add(i);  
        }  
    } 
}  

//声明接口
interface SetObserver {  
    void added(ObservableSet set, E element);  
}  

上述代码将产生ConcurrentModificationException异常。在上面这段代码中,当notifyElementAdded调用观察者的added方法时,它正处于遍历observers列表的过程中。而added方法最终会调用observers.remove方法。程序企图在遍历列表的过程中,将一个元素从列表中删除,这是非法的。

若我们用另一个线程来完成取消通知的操作,但是不直接调用removeObserver。如下面的代码使用了一个executor service:

set.addObserver(new SetObserver() {      
    @Override  
    public void added(final ObservableSet set, Integer element) {  
        System.out.print(element+" ");  
        if (element == 23) {  
            ExecutorService executorService = Executors.newSingleThreadExecutor();  
              
            final SetObserver observer = this;  
            try {  
                executorService.submit(new Runnable() {  
                    @Override  
                    public void run() {  
                        set.removeObserver(observer);  
                    }  
                }).get();  
            } catch (ExecutionException ex) {  
                throw new AssertionError(ex.getCause());  
            }catch (InterruptedException ex) {  
                throw new AssertionError(ex.getCause());  
            }finally{  
                executorService.shutdown();  
            }  
        }  
    }  
});  

上面的代码将出现死锁。后台线程调用set.removeObserver,它企图锁定observers,但该锁已经被主线程持有。而主线程则一直在等待后台程序来完成对观察者的删除,这就是造成死锁的原因。

通过将外来的方法调用移出同步代码块可以有效的解决上述的死锁和异常问题。java1.5后,java类库提供了一个并发集合「CopyOnWriteArrayList」,它是ArrayList的一种变体,通过重新拷贝整个底层数组,实现所有的写操作。如:

private final List> observers = new CopyOnWriteArrayList>();
    
    public void addObserver(SetObserver observer) {
        observers.add(observer);
    }

    public boolean removeObserver(SetObserver observer) {
        return observers.remove(observer);
    }

    private void notifyElementAdded(E element) {
        for (SetObserver observer : observers) {
                observer.added(this, element);
        }
}

过度同步将影响程序的性能,原因:

  • 程序将失去并行的机会
  • cpu需要确保每个核有一个一致的内存视图而导致延迟
  • 限制了JVM优化代码的能力

总之,为了避免死锁和数据破坏,千万不要从同步区域内调用外来的方法。要尽量限制同步区域内部的工作量。当你设计一个可变类的时候,要考虑一下他们是否应该自己完成同步操作。只有当你有足够的理由一定要在内部同步类的时候,才可以这样做,同时还应该将这个决定清楚的写在文档中。

68、executor和task优先于线程

在java1.5后,java平台增加了「Executor Framework」,这是一个灵活的基于接口的任务执行工具。它创建了一个工作队列用于执行任务。如:

ExecutorService executor = Executors.newSingleThreadExecutor();
executor.execute(new Runnable {
    @Override
    public void run() {
        .....
    }
});
....
executor.shutdown(); //终止前允许执行以前提交的任务

若想让多个线程来处理这个队列中的任务,可以使用Executors.newCachedThreadPool()

不仅应该尽量不要编写自己的工作队列,而且还应该尽量不直接使用线程。现在工作单元和执行机制是分开的。工作单元也称作「任务」。任务有两种:「Runnable」和「Callable」。执行机制一般为「executor service」。

69、开发工具优先于wait和notify

。。。。

直接使用wait和notify就像用“并发汇编语言”进行编程一样,而java.util.concurrent则提供了更高级的语言。没有理由在新代码中使用wait和notify,即使有,也是极少的。如果你在维护使用wait和notify的代码,务必确保始终是利用标准的模式从while循环内部调用wait。一般情况下,你应该优先使用notifyAll,而不是使用notify。如果使用notify,请一定要小心,以确保程序的活性。

70、线程安全性的文档化

线程安全性有多种级别。一个类为了可被多个线程安全的使用,必须在文档中清楚的说明它所支持的线程安全级别。

常见的线程安全级别有:

  • 不可变的(immutable)—这个类的实例是不可变的,不需要外部同步。如,「String」、「Long」和「BigInteger」。
  • 无条件的线程安全(thread-safe)—这个类的实例是可变的,但是其有足够的内部同步,无需任何外部同步。如,「Random」和「ConcurrentHashMap」。
  • 有条件的线程安全(thread-safe)—除了一些方法需要外部同步之外,其它与无条件的线程安全相同。如,Collections.synchronized返回的集合,它们的迭代器(iterator)要求外部同步。
  • 非线程安全(not thread-safe)—这个类的实例是可变的,为了并发的使用它们,每个方法都需要外部同步。如,「ArrayList」和「HashMap」等。
    -线程对立的(thread-hostile)—即使使用外部同步,这个类也不能被多个线程并发使用。线程对立的根源一般在于没有同步的修改静态数据。

对于有条件的线程安全必须在文档中指明哪个调用序列需要外部同步,还要指明为了线程同步必须获得哪把锁。如,「Collections.synchronizedMap」的文档


/**
 * It is imperative that the user manually synchronize on the returned map 
 * when iterating over any of its collection views:
 */

Map m = Collections.synchronizedMap(new HashMap());
...
Set s = m.keySet();
...
synchronized(m) { //同步m,不是s
    for(K key : s) {
        key.f();
    }
}

对于无条件的线程安全类,应该考虑使用私有锁对象来代替同步的方法(把锁对象封装在它所同步的对象中)。这样可以防止客户端和子类的不同步干扰,如客户端超时的持有公有类的锁,将导致这个类的同步方法不能访问。

private final Object lock = new Object();
public void foo() {
    synchronized(lock) { //使用私有锁对象代替同步方法
        ...
    }
}

私有锁对象模式适用于那些专门为继承而设计的类,如这种类使用它的实例作为锁对象,子类可能很容易在无意中妨碍基类的操作。

71、慎用延迟初始化

「延迟初始化」是延迟到需要域的值时才将它初始化的行为。像大多数优化一样,对于「延迟初始化」,除非绝对必要,否则不要这样做

「延迟初始化」降低了初始化类或者创建实例的开销,却增加了访问被延迟初始化的域的开销。在大多数情况下,正常的初始化要优先于延迟初始化。若利用延迟优化,就要使用同步访问方法。如:

//直接初始化
private final FieldType field = computeFieldValue();

//延迟初始化,必须同步
private FieldType field;
synchronized FieldType getField() {
    if(field == null) 
        field = computeFieldValue();
    return field;
}

若出于性能的考虑,需要对静态域使用延迟初始化,就是用lazy initialization holder class模式。如:

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

当getField方法第一次被调用时,FiledHolder类得到初始化。这种模式的好处在于,getField方法没有被同步,并且只执行一个域访问(原子操作),因此延迟初始化没有增加任何访问成本。

若出于性能的考虑,需要对实例域使用延迟初始化,就是用双重检查模式。如:

private volatile FieldType field; //volatile很重要
FieldType getField() {
    if(field == null) {
        synchronized(this) {
            if(field == null) {
                field = computeFieldValue();
            }
        }
    }
}

总之,大多数域应该正常进行初始化,而不是延迟初始化。

72、不要依赖于线程调度器

任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能都是不可移植的。线程优先级是Java平台上最不可移植的特征了。Thread.yield的唯一用途是在测试期间人为地增加程序的并发性。

总之,不要让应用程序的正确性依赖于线程调度器,不要依赖Thread.yield或者线程优先级。否则,应用程序将既不健壮,也不具有可移植性。

73、避免使用线程组

不要使用线程组。如果你正在设计的一个类需要处理线程的逻辑组,可以使用线程池executor。

你可能感兴趣的:(Effective java笔记(九),并发)