pursue wind pursue wind
首页
Java
Python
数据库
框架
Linux
中间件
前端
计算机基础
DevOps
项目
面试
书
关于
归档
MacOS🤣 (opens new window)
GitHub (opens new window)
首页
Java
Python
数据库
框架
Linux
中间件
前端
计算机基础
DevOps
项目
面试
书
关于
归档
MacOS🤣 (opens new window)
GitHub (opens new window)
  • EffectiveJava

    • 考虑使用静态工厂方法替代构造方法
    • 当构造方法参数过多时使用builder模式
    • 使用私有构造方法或枚类实现Singleton属性
    • 使用私有构造方法执行非实例化
    • 依赖注入优于硬连接资源(hardwiring resources)
    • 避免创建不必要的对象
    • 消除过期的对象引用
    • 避免使用Finalizer和Cleaner机制
    • 使用try-with-resources语句替代try-finally语句
    • 重写equals方法时遵守通用约定
    • 重写equals方法时同时也要重写hashcode方法
    • 始终重写 toString 方法
    • 谨慎地重写 clone 方法
    • 考虑实现Comparable接口
    • 使类和成员的可访问性最小化
    • 在公共类中使用访问方法而不是公共属性
    • 最小化可变性
    • 组合优于继承
    • 要么设计继承并提供文档说明,要么禁用继承
    • 接口优于抽象类
    • 为后代设计接口
    • 接口仅用来定义类型
    • 类层次结构优于标签类
    • 支持使用静态成员类而不是非静态类
    • 将源文件限制为单个顶级类
    • 不要使用原始类型
    • 消除非检查警告
    • 列表优于数组
    • 优先考虑泛型
    • 优先使用泛型方法
    • 使用限定通配符来增加API的灵活性
    • 合理地结合泛型和可变参数
    • 优先考虑类型安全的异构容器
    • 使用枚举类型替代整型常量
    • 使用实例属性替代序数
    • 使用EnumSet替代位属性
    • 使用EnumMap替代序数索引
    • 使用接口模拟可扩展的枚举
    • 注解优于命名模式
    • 始终使用Override注解
    • 使用标记接口定义类型
    • lambda表达式优于匿名类
    • 方法引用优于lambda表达式
    • 优先使用标准的函数式接口
    • 明智审慎地使用Stream
    • 优先考虑流中无副作用的函数
    • 优先使用Collection而不是Stream来作为方法的返回类型
    • 谨慎使用流并行
    • 检查参数有效性
    • 必要时进行防御性拷贝
    • 仔细设计方法签名
    • 明智审慎地使用重载
    • 明智审慎地使用可变参数
    • 返回空的数组或集合,不要返回 null
    • 明智审慎地返回 Optional
    • 为所有已公开的 API 元素编写文档注释
    • 最小化局部变量的作用域
    • for-each 循环优于传统 for 循环
    • 了解并使用库
    • 若需要精确答案就应避免使用 float 和 double 类型
    • 基本数据类型优于包装类
    • 当使用其他类型更合适时应避免使用字符串
    • 当心字符串连接引起的性能问题
    • 通过接口引用对象
    • 接口优于反射
    • 明智审慎地本地方法
    • 明智审慎地进行优化
    • 遵守被广泛认可的命名约定
    • 只针对异常的情况下才使用异常
    • 对可恢复的情况使用受检异常,对编程错误使用运行时异常
    • 避免不必要的使用受检异常
    • 优先使用标准的异常
    • 抛出与抽象对应的异常
    • 每个方法抛出的异常都需要创建文档
    • 在细节消息中包含失败一捕获信息
    • 保持失败原子性
    • 不要忽略异常
    • 同步访问共享的可变数据
    • 避免过度同步
    • executor 、task 和 stream 优先于线程
    • 并发工具优于 wait 和 notify
    • 文档应包含线程安全属性
    • 明智审慎的使用延迟初始化
    • 不要依赖线程调度器
    • 优先选择 Java 序列化的替代方案
    • 非常谨慎地实现 Serializable
    • 考虑使用自定义的序列化形式
    • 保护性的编写 readObject 方法
    • 对于实例控制,枚举类型优于 readResolve
    • 考虑用序列化代理代替序列化实例
  • On Java 8

  • 书
  • EffectiveJava
pursuewind
2020-11-22

避免过度同步

# 79. 避免过度同步

第 78 条告诫过我们缺少同步的危险性。本条目则关注相反的问题。依据情况的不同,过度同步则可能导致性能降低、死锁,甚至不确定的行为。

为了避免活性失败和安全性失败,在一个被同步的方法或者代码块中,永远不要放弃对客户端的控制。 换句话说,在一个被同步的区域内部,不要调用设计成要被覆盖的方法,或者是由客户端以函数对象的形式提供的方法(详见第 24 条) 。从包含该同步区域的类的角度来看,这样的方法是外来的( alien ) 。这个类不知道该方法会做什么事情,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。

为了对这个过程进行更具体的说明,以下面的类为例,它实现了一个可以观察到的集合包装( set wrapper ) 。该类允许客户端在将元素添加到集合中时预订通知。这就是观察者(Observer )模式[Gamma95 ] 。为了简洁起见,类在从集合中删除元素时没有提供通知,但要提供通知也是一件很容易的事情。这个类是在第 18 条中可重用的 ForwardingSet 上实现的:

// Broken - invokes alien method from synchronized block!
public class ObservableSet<E> extends ForwardingSet<E> {
    public ObservableSet(Set<E> set) { super(set); }
    
    private final List<SetObserver<E>> observers= new ArrayList<>();

    public void addObserver(SetObserver<E> observer) {
        synchronized(observers) {
            observers.add(observer);
        }
    }
    
    public Boolean removeObserver(SetObserver<E> observer) {
        synchronized(observers) {
            return observers.remove(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);
        // Calls notifyElementAdded
        return result;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

观察者通过调用 addObserver 方法预订通知,通过调用 removeObserver 方法取消预订。在这两种情况下,这个回调( callback )接口的实例都会被传递给方法:

@FunctionalInterface 
public interface SetObserver<E> {
    // Invoked when an element is added to the observable set
    void added(ObservableSet<E> set, E element);
}
1
2
3
4
5

这个接口的结构与 BiConsumer<Obser vableSet<E> ,E> 一样。我们选择定义一个定制的函数接口,因为该接口和方法名称可以提升代码的可读性,且该接口可以发展整合多个回调。也就是说,还可以设置合理的参数来使用 BiConsumer (详见第 44 条)。

如果只是粗略地检验一下, ObservableSet 会显得很正常。例如,下面的程序打印出 0 ~ 99 的数字:

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

现在我们来尝试一些更复杂点的例子。假设我们用一个 addObserver 调用来代替这个调用,用来替换的那个 addObserver 调用传递了一个打印 Integer 值的观察者,这个值被添加到该集合中,如果值为 23 ,这个观察者要将自身删除:

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

注意,这个调用以一个匿名类 SetObserver 实例代替了前一个调用中使用的 lambda 。这是因为函数对象需要将自身传给 s.removeObserver ,而 lambda 则无法访问它们自己(详见第 42 条) 。

你可能以为这个程序会打印数字 0 ~ 23 ,之后观察者会取消预订,程序会悄悄地完成它的工作。实际上却是打印出数字 0 ~ 23 ,然后抛出 ConcurrentModificationException 。问题在于,当 notifyElementAdded 调用观察者的 added 方法时,它正处于遍历 observers 列表的过程中。added 方法调用可观察集合的 removeObserver 方法,从而调用 observers.remove 。现在我们有麻烦了。我们正企图在遍历列表的过程中,将一个元素从列表中删除,这是非法的。notifyElementAdded 方法中的迭代是在一个同步的块中,可以防止并发的修改,但是无法防止迭代线程本身回调到可观察的集合中,也无法防止修改它的 observers 列表。

现在我们要尝试一些比较奇特的例子: 我们来编写一个试图取消预订的观察者,但是不直接调用 removeObserver ,它用另一个线程的服务来完成。这个观察者使用了一个 executor service (详见第 80 条):

// Observer that uses a background thread needlessly
set.addObserver(new SetObserver<>() {
    public void added(ObservableSet<Integer> s, Integer e) {
        System.out.println(e);
        if (e == 23) {
            ExecutorService exec =
            Executors.newSingleThreadExecutor();
            try {
                exec.submit(() -> s.removeObserver(this)).get();
            }
            catch (ExecutionException | InterruptedException ex) {
                throw new  AssertionError (ex);
            }
            finally {
                exec.shutdown();
            }
        }
    }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

顺便提一句,注意看这个程序在一个 catch 子句中捕获了两个不同的异常类型。这个机制是在 Java 7 中增加的,不太正式地称之为多重捕获( multi-catch ) 。它可以极大地提升代码的清晰度,行为与多异常类型相同的程序,其篇幅可以大幅减少。

运行这个程序时,没有遇到异常,而是遭遇了死锁。后台线程调用 s.removeObserver,它企图锁定 observers,但它无法获得该锁,因为主线程已经有锁了。在这期间,主线程一直在等待后台线程来完成对观察者的删除,这正是造成死锁的原因。

这个例子是刻意编写用来示范的,因为观察者实际上没理由使用后台线程,但这个问题却是真实的。从同步区域中调用外来方法,在真实的系统中已经造成了许多死锁,例如 GUI 工具箱。

在前面这两个例子中(异常和死锁),我们都还算幸运。调用外来方法( added )时,同步区域( observers )所保护的资源处于一致的状态。假设当同步区域所保护的约束条件暂时无效时,你要从同步区域中调用一个外来方法。由于 Java 程序设计语言中的锁是可重入的( reentrant ),这种调用不会死锁。就像在第一个例子中一样,它会产生一个异常,因为调用线程已经有这个锁了,因此当该线程试图再次获得该锁时会成功,尽管概念上不相关的另一项操作正在该锁所保护的数据上进行着。这种失败的后果可能是灾难性的。从本质上来说,这个锁没有尽到它的职责。可重入的锁简化了多线程的面向对象程序的构造,但是它们可能会将活性失败变成安全性失败。

幸运的是,通过将外来方法的调用移出同步的代码块来解决这个问题通常并不太困难。对于 notifyElementAdded 方法,这还涉及给 observers 列表拍张“快照”,然后没有锁也可以安全地遍历这个列表了。经过这一修改,前两个例子运行起来便再也不会出现异常或者死锁了:

// Alien method moved outside of synchronized block - open calls
private void notifyElementAdded(E element) {
    List<SetObserver<E>> snapshot = null;
    synchronized(observers) {
        snapshot = new ArrayList<>(observers);
    }
    for (SetObserver<E> observer : snapshot)
        observer.added(this, element);
}
1
2
3
4
5
6
7
8
9

事实上,要将外来方法的调用移出同步的代码块,还有一种更好的方法。Java 类库提供了一个并发集合( concurrent collection ),详见第 81 条,称作 CopyOnWriteArrayList,这是专门为此定制的。这个 CopyOnWriteArrayList 是 ArrayList 的一种变体,它通过重新拷贝整个底层数组,在这里实现所有的写操作。由于内部数组永远不改动,因此迭代不需要锁定,速度也非常快。如果大量使用, CopyOnWriteArrayList 的性能将大受影响,但是对于观察者列表来说却是很好的,因为它们几乎不改动,并且经常被遍历。

如果将这个列表改成使用 CopyOnWriteArrayList ,就不必改动 ObservableSet 的 add 和 addAll 方法。下面是这个类的其余代码。注意其中并没有任何显式的同步:

// Thread-safe observable set with CopyOnWriteArrayList
private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<>();

public void addObserver(SetObserver<E> observer) {
    observers.add(observer);
}

public Boolean removeObserver(SetObserver<E> observer) {
    return observers.remove(observer);
}

private void notifyElementAdded(E element) {
    for (SetObserver<E> observer : observers)
        observer.added(this, element);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在同步区域之外被调用的外来方法被称作“开放调用”(open call)[Goetz06, 10 .1.4]。除了可以避免失败之外,开放调用还可以极大地增加并发性。外来方法的运行时间可能为任意时长。如果在同步区域内调用外来方法,其他线程对受保护资源的访问就会遭到不必要的拒绝。

通常来说,应该在同步区域内做尽可能少的工作。 获得锁,检查共享数据,根据需要转换数据,然后释放锁。如果你必须要执行某个很耗时的动作,则应该设法把这个动作移到同步区域的外面,而不违背第 78 条中的指导方针。

本条目的第一部分是关于正确性的。接下来,我们要简单地讨论一下性能。虽然自 Java 平台早期以来,同步的成本已经下降了,但更重要的是,永远不要过度同步。在这个多核的时代,过度同步的实际成本并不是指获取锁所花费的 CPU 时间;而是指失去了并行的机会,以及因为需要确保每个核都有一个-致的内存视图而导致的延迟。过度同步的另一项潜在开销在于,它会限制虚拟机优化代码执行的能力。

如果正在编写一个可变的类,有两种选择:省略所有的同步,如果想要并发使用,就允许客户端在必要的时候从外部同步,或者通过内部同步,使这个类变成是线程安全的(详见第 82 条),你还可以因此获得明显比从外部锁定整个对象更高的并发性。java.util 中的集合(除了已经废弃的 Vector 和 Hashtable 之外)采用了前一种方法,而 java.util.concurrent 中的集合则采用了后一种方法(详见第 81 条)。

在 Java 平台出现的早期,许多类都违背了这些指导方针。例如, StringBuffer 实例几乎总是被用于单个线程之中,而它们执行的却是内部同步。为此, StringBuffer 基本上都由 StringBuilder 代替,它是一个非同步的 StringBuffer 。同样地,java.util.Random 中线程安全的伪随机数生成器,被 java.util.concurrent.ThreadLocalRandom 中非同步的实现取代,主要也是出于上述原因。当你不确定的时候,就不要同步类,而应该建立文档,注明它不是线程安全的。

如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如分拆锁、分离锁和非阻塞并发控制。这些方法都超出了本书的讨论范围,但有其他著作对此进行了阐述[Goetz06, Herlihy12] 。

如果方法修改了静态字段,并且该方法很可能要被多个线程调用,那么也必须在内部同步对这个字段的访问(除非这个类能够容忍不确定的行为) 。多线程的客户端要在这种方法上执行外部同步是不可能的,因为其他不相关的客户端不需要同步也能调用该方法。字段本质上就是一个全局变量,即使是私有的也一样,因为它可以被不相关的客户端读取和修改。第 78 条中的 generateSerialNumber 方法使用的 nextSerialNumber 字段就是这样的一个例子。

总而言之,为了避免死锁和数据破坏,千万不要从同步区字段内部调用外来方法。更通俗地讲,要尽量将同步区字段内部的工作量限制到最少。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。在如今这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部同步类的时候,才应该这么做,同时还应该将这个决定清楚地写到文档中(详见第 82 条) 。

Last Updated: 2023/01/30, 11:01:00
同步访问共享的可变数据
executor 、task 和 stream 优先于线程

← 同步访问共享的可变数据 executor 、task 和 stream 优先于线程→

Theme by Vdoing | Copyright © 2019-2023 pursue-wind | 粤ICP备2022093130号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式
  • 飙升榜
  • 新歌榜
  • 云音乐民谣榜
  • 美国Billboard榜
  • UK排行榜周榜
  • 网络DJ