哈尔滨工业大学软件构造课程笔记第七章第二节

    技术2022-07-11  90

    7.2 线程安全

    1.什么是线程安全

    线程安全 竞争条件:多个线程共享同一个可变变量,但不协调它们正在做的事情。

    这是不安全的,因为程序的正确性可能依赖于低级操作的定时事故。

    线程之间的“竞争条件”:作用于同一个mutable数据上的多个线程,彼此之间存在对该数据的访问竞争并导致interleaving,导致postcondition可能被违反,这是不安全的。

    threadsafe是什么意思 线程安全:ADT或方法在多线程中要执行正确

    如何抓住这个想法? 不违反spec、保持RI 与多少处理器、OS如何调度线程,均无关 不需要在spec中强制要求client满足某种“线程安全”的义务

    Iterator是不线程安全的,Iterator的规范规定,不能在对集合进行迭代的同时修改集合。这是设置在调用者上的与时间相关的前置条件如果你违反了它,Iterator不能保证你的行为是正确的

    threadsafe是什么意思:remove()的spec 作为这种非本地合同现象的一个症状,考虑到Java集合类,通常在客户端和类的实现者之间用非常清晰的契约来记录。 -试着找到它在哪里记录了客户端的关键需求,即当你迭代一个集合时你不能修改它。 threadsafe的四种方式 限制数据共享 共享不可变数据 共享线程安全的可变数据 同步机制:通过锁的机制共享线程不安全的可变数据,变并行为串行

    策略1:Confinement限制数据共享

    限制数据共享 将可变数据限制在单一线程内部,避免竞争 不允许任何线程直接读写该数据 核心思想:线程之间不共享mutable数据类型 局部变量总是线程限制。一个局部变量存储在堆栈中,并且每个线程都有自己的堆栈。一个方法可能有多个调用同时运行,但是每个调用都有自己的变量私有副本,因此变量本身是受限的。 如果一个局部变量是一个对象引用,你需要检查它指向的对象。如果对象是可变的,那么我们需要检查对象是否也被限制了——不能有任何其他线程可访问(非别名)的对它的引用

    内部Java内存模型 JVM内部使用的Java内存模型在线程堆栈和堆之间划分内存。 内部Java内存模型:堆栈 每个线程有自己的栈 栈中包含所有方法的局部变量 线程只能访问自己的线程栈 线程创建的局部变量其他线程不可见 即使两个线程的代码一样,创建的同名变量仍然在各自的栈中 每个线程有自己版本的局部变量

    内部Java内存模型:堆 基本类型的局部变量保存在线程栈中 一个线程可能会将基本类型变量的副本传递给另一个线程,但它本身不能共享基本类型局部变量 对象类型数据保存在堆中 如果对象被指派到某个局部变量,或者作为其他对象的成员变量,创建的对象仍然在堆中

    Java内存模式的几个关键点 基本数据类型的局部变量保存在线程栈中 局部变量引用了对象,引用保存在栈中,对象本身存储在堆中 对象包含的方法和方法包含的局部变量存储在栈中 对象的成员变量同对象一起存储在堆中,不论成员变量的类型是基本类型还是对象类型(对其他对象的引用) 静态的类变量同类的定义一起保存在堆中 堆中的对象可以被所有拥有引用的线程访问 如果两个线程同时调用同一个对象上的一个方法,它们都可以访问该对象的成员变量,但是每个线程都有自己的局部变量副本。 避免全局变量 全局静态变量不会自动被线程限制。 如果你的程序中有静态变量,那么你必须证明只有一个线程会使用它们,并且你必须清楚地记录这个事实。 更好的是,应该完全消除静态变量

    限制数据共享和filed 即使实例变量被声明为私有,它们也不会自动受到线程限制。 如果一个ADT的rep中包含mutable的属性且多线程之间对其进行mutator操作,那么就很难使用confinement策略来确保该ADT是线程安全的

    策略2:共享不可变数据Immutability

    策略2:共享不可变数据 使用不可变数据类型和不可变引用,避免多线程之间的race condition 声明的final变量是不可重新分配的和不可变的引用,所以声明的final变量是安全的,可以从多个线程访问。 -你只能读变量,不能写它。 -因为这种安全性只适用于变量本身,而且我们仍然必须证明变量指向的对象是不可变的。

    不可变数据通常是线程安全的 我们说“通常”是因为当前的不变性定义对并行程序来说太松了 对于并发编程,这种隐藏的变化有时是不安全的 如果ADT中使用了beneficent mutation,必须要通过“加锁”机制来保证线程安全

    更强的不变性定义 为了确信不可变数据类型在没有锁的情况下是线程安全的,我们需要一个更强的不可变性定义: -没有mutator方法 -所有字段都是private和final -无代表暴露 -没有任何变化的对象在代表-甚至没有有益的变化

    如果您遵循这些规则,那么您就可以确信您的不可变类型也是threadsafe的

    共享不可变数据和线程安全 假设您正在检查一个指定为不可变的ADT,以决定它的实现是否实际上是不可变的和线程安全的。 以下哪一个元素是你必须考虑的? ▪ 相比起策略1 Confinement,该策略2 Immutability允许有全局rep,但是只能是immutable的。

    策略3:共享线程安全的可变数据

    策略3:共享线程安全的可变数据 如果必须要用mutable的数据类型在多线程之间共享数据,要使用线程安全的数据类型 在JDK中的类,文档中明确指明了是否threadsafe

    一般来说,JDK同时提供两个相同功能的类,一个是threadsafe,另一个不是。 原因:threadsafe的类一般性能上受影响

    例如:StringBuffer 和StringBuilder 线程安全的集合 Java中的集合接口List、Set、Map都是线程不安全的 实现即ArrayList、HashMap和HashSet不能在多个线程中安全地使用。 Java API提供了进一步的decorator 对它们的每一个操作调用,都以原子方式执行 不会与其他操作interleaving

    private static Map<Integer,Boolean> cache = Collections.synchronizedMap(new HashMap<>());

    线程安全的包装器

    public static <T> Collection<T> synchronizedCollection(Collection<T> c); public static <T> Set<T> synchronizedSet(Set<T> s); public static <T> List<T> synchronizedList(List<T> list); public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m); public static <T> SortedSet<T> synchronizedSortedSet(SortedSet<T> s); public static <K,V> SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V> m);

    ▪包装实现委托他们所有的实际工作到一个指定的集合,但在这个集合提供的顶部增加额外的功能。 ▪这是一个装饰模式的例子(参见5-3节) ▪这些实现是匿名的;该库没有提供公共类,而是提供了静态工厂方法。 ▪所有这些实现都可以在Collections类中找到,它只包含静态方法。 ▪同步包装器增加了自动同步(线程安全)到任意集合。

    不要绕开包装器 ▪确保丢弃对底层非线程安全集合的引用,并且只通过同步包装器访问它。 ▪新的HashMap只被传递到synchronizedMap()上,而不会被存储到其他地方。 ▪底层集合仍然是可变的,引用它的代码可以规避不变性。 ▪ 在使用synchronizedMap(hashMap)之后,不要再把参数hashMap共 享给其他线程,不要保留别名,一定要彻底销毁

    迭代器仍然不是线程安全的 即使在线程安全的集合类上,使用iterator也是不安全的 所以你不能使用iterator()或者for循环语法 除非使用lock机制

    List<Type> c = Collections.synchronizedList(new ArrayList<Type>()); synchronized(c) { for (Type e : c) foo(e); }

    原子操作不足以防止竞争 即使是线程安全的collection类,仍可能产生竞争 – 执行其上某个操作是threadsafe的,但如果多个操作放在一起,仍旧不安全

    必须指出,containsKey()、get()和put()之间的竞争不会威胁到这个不变量。 containsKey()和get()之间的竞争并不有害,因为我们从不从缓存中删除条目——一旦它包含了x的结果,它将继续这样做 containsKey()和put()之间存在竞争。结果,两个线程可能会同时测试同一个x的初始值,并且都将争着调用put()来得到结果。但是它们都应该以相同的答案调用put(),因此谁赢并不重要—结果将是相同的 即使在使用线程安全的数据类型时,也需要对安全性进行仔细的讨论,这是并发性很难实现的主要原因。

    总结 在共享的可变数据上实现安全的三种主要方法: -限制:不共享数据。 -不变性:共享,但保持数据不可变。 -线程安全数据类型:将共享的可变数据存储为单一的线程安全数据类型。

    ▪安全远离bug。 我们试图消除一个主要的并发错误类,竞争条件,并通过设计消除它们,而不仅仅是偶然的时间。 ▪易于理解。 -应用这些通用的、简单的设计模式要比讨论哪些线程交错是可能的、哪些不可能的复杂得多。 ▪做好改变的准备。 -我们在线程安全论证中明确写下这些理由,以便维护程序员知道代码的线程安全依赖于什么。

    5.如何撰写线程安全策略

    回忆:开发ADT的步骤 spec:定义操作(方法签名和规范) 测试:为操作开发测试用例 rep:选择一个rep。 -首先实现一个简单的,粗暴的代表。 -写下代表不变式和抽象函数,并实现checkRep(),它在每个构造函数、生成器和mutator方法的末尾断言代表不变式。 +同步 -证明你的rep是线程安全的。 -将其作为注释显式地写在类中,就在代表不变式的右边,以便维护人员知道您是如何将线程安全设计到类中的。

    撰写线程安全策略 并发性很难测试和调试! 在代码中以注释的形式增加说明:该ADT采取了什么设计决策来保证线程安全 阐述清楚采取了四种方法中的哪一种 如果是后两种,还需考虑对数据的访问都是原子的,不存在interleaving

    Confinement的线程安全策略 除非你知道线程访问的所有数据,否则Confinement无法彻底保证线程安全 如果数据类型创建了它自己的一组线程,那么您可以讨论关于这些线程的限制。 除非是在ADT内部创建的线程,可以清楚得知访问数据 有哪些 在那种情况下,Confinement不是一个有用的论点。 通常我们在更高的层次上使用Confinement,将系统作为一个整体来讨论,并讨论为什么我们不需要某些模块或数据类型的线程安全,因为它们不会被设计为在线程之间共享。

    6.总结

    本文讨论了在共享可变状态下实现安全避免竞争条件的三种主要方法: -限制:不共享变量或数据。 -不变性:共享,但保持数据不可变和变量不可重新分配。 -线程安全数据类型:将共享的可变数据存储为单一的线程安全数据类型。

    防止bug。我们试图消除一类主要的并发错误、竞态条件,并通过设计来消除它们,而不仅仅是通过偶然的时机。 -很容易理解。应用这些通用的、简单的设计模式要比讨论哪些线程交错可能、哪些线程交错不可能的复杂得多。 -为改变做好准备。我们在线程安全参数中明确地写下这些理由,以便维护程序员知道代码的线程安全依赖于什么

    Processed: 0.011, SQL: 9