java培训:哪些集合类是线程安全的

在Java集合类中,有线程安全类和非线程安全类。
使用线程安全的集合类可以避免多线程并发访问时出现的线程安全问题。
以下是常见的线程安全集合类:VectorVector类是较早的集合类,它提供了线程安全的功能,可以在多线程环境中安全地使用。
但由于内部使用了synchronized代码块来保证线程安全,因此在高并发情况下性能会受到影响。
示例代码:HashtableHashtable是一个古老的Map集合类,它也提供了线程安全功能,可以在多线程环境下安全使用。
底层通过同步方法实现线程安全。
示例代码:Collections.synchronizedList(Listlist)Collections提供的这个方法可以将非线程安全的List转换为线程安全的版本。
返回的List对象可以在多线程环境中安全使用。
底层使用synchronized代码块实现线程安全,高并发情况下性能受到影响。
示例代码:Java5引入了线程安全的集合类来提供更好的性能,通常会结合读写分离技术来保证线程安全。
ConcurrentHashMapConcurrentHashMap是一个线程安全的Map集合类,支持多线程并发访问和修改,无需同步操作,比Hashtable效率更高。
示例代码:CopyOnWriteArrayListCopyOnWriteArrayList是一个线程安全的List集合类,支持多线程并发访问和修改,无需同步操作。
读操作是无锁的,写操作通过复制原始数据来分隔,修改完成后替换原始数据。
示例代码:综上所述,线程安全集合类可以有效避免线程安全问题。
在多线程高并发场景下,建议使用性能更好的线程安全集合类。

Java并发编程基础:线程安全与竞态条件

focus:darculatheme:awesome-green

本专栏学习笔记整理自机械工业出版社出版的《Java并发编程实践》。

本章小结

我们什么时候应该考虑线程安全?

Check-After-Act策略引入的竞争条件

原子操作的定义

锁的基本使用

流程为资源分配的最小单位,线程是CPU执行的最小单位。
一个进程中有很多线程,这些线程共享整个进程的资源(文件句柄、内存句柄),但每个线程都有独立的程序计数器(ProgramCounter)、操作数栈和局部变量表。
参见作者的JVM笔记:JVM:Java-掘金内存区域分配(juejin.cn)和JVM:字节码-掘金执行引擎(juejin.cn)。

同一进程中的多个线程共享同一地址空间,可以访问和修改同一内存空间中的变量。
如果没有任何协调机制,所有线程都将独立运行。
每个线程都是“自私的”:当它修改一个变量时,它不会考虑其他线程是否也会读取或修改同一个变量。

基本问题

要构建稳定的并发程序,必须正确使用线程和锁,这些只是用于实现并发编程的几种机制。
要编写线程安全的代码,关键是共享和可变的状态访问控制。

分布式:变量可以被多个线程访问。

变量:变量的值在其生命周期内可以更改。

当我们用“状态”这个词来描述一个域(或成员变量、属性等)时,意味着它是一个变量(变量),而不是一个值(最终值)。
此外,状态包含可以描述其字段的所有数据。
例如:一个HashMap域的状态不仅包括它的引用,还包括HashMap内所有条目的状态。

//?嗯?已被?final?关键字修改,但实际上仍处于可变状态。
public?fin​​al?HashMap?hm?=?new?HashMap<>();public?void?addKey(String?k,?String?v){hm.put(k,v);}

如果希望状态是线程安全的,就必须引入加锁机制进行保护。
Java提供了多种synchronized关键字、原子变量、易失性锁、显式等手段来实现这一目标,本章将介绍前两种。

关于线程安全

当我们讨论线程安全时,实际上是在讨论并发环境下程序执行的正确性展开来说,它的意思是:一个对象的行为完全符合规范。
我们将定义两个规范:

InvariantConditions,即始终为真而不改变的条件,可以称为“属性”,用于约束对象的状态。
例如:当银行账户A向B发起转账时,两个账户的余额之和必须始终保持不变。

后置条件,即执行操作后满足的条件,用于描述对象操作的结果。
例如:账户B收到一笔转账后,当前余额一定是之前余额与交易金额之和。

并非所有程序并行必须考虑并发安全问题。
如果多个线程对不同的对象进行操作而不共享任何状态,那么这段代码实际上是并行的而不是并发的。

在大型的面向控制的系统中,通常会考虑并发问题,因为系统依赖各种全局变量来维护状态,除了基于锁定机制的维护并发系统外,甚至还有基于actor模型的并发系统,比如Akka(基于actor的并发控制比基于阻塞的并发控制更简单)。
在面向计算机的大规模系统中,优先考虑并行调度问题,例如Spark。

此外,无状态对象必须是线程安全的。
无状态对象不包含任何字段,也不包含对其他字段的任何引用。

请参见下面的构造函数方法:它是一个自包含的闭包,并且不引用内部的任何自由变量。
即使多个线程访问同一个ArrayBuilder对象的构造方法,局部变量的数量也存储在不同栈帧下的局部变量表中。
线程之间没有共享状态,因此它们不会干扰彼此的计结果。

class?ArrayBuilder{public?void?build(int[]?arr){int?count?=?0;for(int?i?=?0?;?i?<=?arr.length?-?1?;?i++,count++)?{?arr[i]?=?i;System.out.printf("count?=?%s\n",count);?}?}

一般来说,有三个确保变量链接安全的方法:

不要在线程之间共享此变量。

将变量设置为不可变。

在访问/修改变量时引入同步机制。

显然,如果一个对象不是单线程正确的,那么它就不应该是多线程安全的。

生活问题和性能问题

安全是“坏事不应该发生”,活泼是“正确的事情应该尽快发生”。
例如:为了保证线程安全,线程A等待另一个线程B释放互斥锁。
如果线程B从不释放资源,则线程A可以永远等待。
生活问题包括堵塞、饥饿、活堵等。
导致活跃度问题的错误很难分析,因为它们依赖于来自不同线程的事件的时间安排,这使得它们很难在单元测试中重现。

在设计良好的并行程序中,多线程可以提高程序性能,但是使用多线程必然会导致更多的执行时间。
当调度程序暂时挂起一个活动进程并运行另一个线程时,必须执行上下文切换操作(ContextSwitch)。

如果并行编程设计不当,CPU将被迫将大部分时间花在切换上下文而不是执行任务上。
同时,当线程之间共享变量时,必须采用同步机制,而这些机制会阻止一些编译器的优化,使得内存缓冲区中的数据无效。
可见,热闹性问题也与表演性问题密切相关,这些内容将在以后的研究中逐步讨论。

原子性

如果将上例中的计数字段移至实例字段,则ArrayBuilder类创建的对象将保存状态。

class?ArrayBuilder{//?count?现在用于记录?build?public?int?count?=?0;?方法被调用的次数?public?void?construct(int[]?arr){?for(int?i?=?0?;?i?<=?arr.length?-?1?;?i++)?{?arr[i]?=?i;?count++;}}

此时,构造方法中的count是一个自由变量(不受构造方法约束的变量)。
枚举存储在字段表(而不是局部变量表)中,同一个对象的枚举可以被多个线程观察和修改。

在单线程环境中,这是常见且完全合理的代码。
但在多线程环境中执行此操作,ArrayBuilder很可能会丢失一些更新数据。
count++是一种紧凑的语法,因此它看起来像一个操作,但实际上并非如此:它包含三个独立的步骤:“读取→修改→写入”,每一步都取决于上一步的状态。

想象一下两个线程同时读取count=0,然后执行递增操作,然后两个线程同时将count值修改为1。
显然这里的柜台是倾斜的。
随着冲突数量的增加,反偏差会越来越大。
这类由于执行时间不正确而导致的错误结果有一个正式名称:竞态条件(RaceCondition)。

竞争条件

当计算的正确性取决于多个线程交替执行的顺序时,就会出现竞争条件。
更简单地说:程序的正确性取决于运气。
最常见的竞争条件发生在“检查然后行动”类型的代码中,因为线程可以根据已变得无效的观察来执行下一个操作。

例如,一个线程注意到某个文件X最初并不存在,并计划创建一个新文件X并写入一些内容。
但是,在程序创建文件之前,另一个程序或用户可能会创建它(初始线程观察等于null)。
这可能会导致各种问题:意外异常、数据覆盖等。

示例:延迟初始化

典型的“先检查然后运行”延迟初始化场景。
延迟初始化背后的想法是,创建成本高昂的对象会被推迟到仅在需要时才加载。

class?LazyInitRace?{//实际上,创建这个“Object”类型的成本可能很高。
private?Object?instance?=?null;public?Object?getInstance(){?//?if()?是一个可观察的动作?if(instance?==?null)?instance?=?new?Objectt();使用getInstance()方法,它会首先观察实例是否为null,然后决定是否初始化或直接返回一个设法执行相同检查的线程B。
但现在,它观察到的实例是否实际上为空取决于不可预测的时间,包括线程A初始化实例所需的时间。
如果线程B无意中判断错误,整个过程就会错误地创建两个实例。

补充:当一个类第一次加载时,它的静态域会被执行一次。
您可以使用此功能来实现单线程安全模式。

class?LazyInitRace?{//?即使加载了?LazyInitRace?,Instance$?也可能不会被加载,直到第一次调用?LazyInitRace?对象的?getInstance?方法:?私有?静态?类?实例${private?static?fin​​al?Object?instance_?=?new?Object();}public?Object?getInstance(){return?Instance$.instance_;}}复合操作

为了避免竞争条件,必须引入原子操作。
例如,两个线程A和B同时修改同一个状态,当任何一个线程想要开始操作时,另一个线程要么已经完成了访问→修改→的过程,要么还没有开始。
此时,两个线程A和B的状态修改操作是原子的。
原子操作旨在访问和修改相同的状态。

如果条件枚举++中的操作是原子的,那么初始存在的竞争条件不存在。
我们将访问→修改→修改这三个过程统称为复合操作,并将这种复合操作变成原子操作,以保证线程安全。

class?ArrayBuilder{private?final?AtomicInteger?count$?=?new?AtomicInteger(0);/?count()?记录build方法被调用的次数public?int?count(){?return?count$.get();}public?void?build(int...?arr){for(int?i?=?0?;?i?<=?arr.length?-?1?;?i++)?{arr[i]?=?i;}count$.incrementAndGet();}}

java.util.concurrent.atomic本身包含了几个类用于提升常见数字类型的原子变量。
在这种情况下,AtomicInteger可以保证对count$的所有操作都是原子的。
并且由于当前的ArrayBuilder只维护了一个count$状态,所以此时ArrayBuilder类本身也可以说是线程安全的。

尽可能使用线程安全对象来管状态将使并发任务的代码更易于维护。

锁定机制

假设当前类中定义了更多状态,并且组合操作变得更加复杂,那么简单地添加更多原子变量是否就足够了?为了说明这个问题,下面以银行转账为例,Bank类保存了两个状态:A和B两个账户的状态。

class?Bank?{//?Final?这里指的是account_A和account_B的引用不能更改。
public?fin​​al?AtomicInteger?account_A?=?new?AtomicInteger(100);public?fin​​al?AtomicInteger?account_B?=?new?AtomicInteger(200);public?变换(AtomicInteger?x,?AtomicInteger?y,int?amount)?{var?a_?=?account_A.get();var?b_?=?account_B.get();.set(a_?-?amount);account_B.set(b_?+?samount);}}

线程安全规则,无论如何多线程运行的时候无论如何交替执行,类的不变性状态都无法被破坏。
例如转账业务中的不变条件之一是:每个线程一开始观察到的A、B两个账户的余额之和必须相等。

虽然account_A和account_B这两个引用是安全的,但是Bank类中仍然存在竞争条件,这可能会导致不正确的结果,因为线程无法同时修改两个账户的金额。
例如,如果另一个线程T2在线程T1“只写入帐户A而不是帐户B”时进入,则可能不满足它所遵守的不变性标准。

因此,如果您想要保持状态一致性,需要更新与单个原子操作中的状态关联的所有状态变量。

Java从语法层面支持原子性:synchronized关键字,也称为内置锁定。
它只能用作语法块,写为:

synchronized(lock){/**/}

任何Java对象都可以用作互斥同步块。
线程在进入同步代码块之前,必须首先获得锁,然后在退出同步代码块(包括抛出异常退出)时释放锁。
当另一个线程T2想要获取T1持有的锁对象上的锁时,它必须阻塞并等待。
如果T1永远不会释放锁,那么T2将无限期地等待。
通过这种形式,Java确保一次只有一个线程可以执行同步代码块。

可以直接将其中的protected变量设置为锁,这种情况下就称该状态受锁保护,也可以将其中的整个对象(this)设置为锁;在这种情况下,所有变量状态对象将受到保护。

synchronized关键字也可以直接在方法声明中指定。
带注释的方法相当于一个包含整个函数体的synchronized代码块,而这个代码块所在的块就是被调用方法的对象(this)本身。
如果是静态同步方法,则会使用描述该对象的类信息的Class对象作为key。

class?Bank?{//?不变性条件:两个账户A?B的余额之和保持不变。
?//?account_A?和?account_B受内置锁保护,因此无需将它们设置为单独的原子变量。
public?Integer?account_A?=?100;public?Integer?account_B?=?200;public?synchronized?void?transform(Integer?amount)?{?account_A?=?account_A?-?金额;?account_B?=?account_B?+?金额;}}重入功能

锁获取操作的亮度这是一个线程,而不是一个调用。
在Java中,线程可以重复进入一个自持块。

class?MutexFoo?{//?这里的块是实例本身?MutexFoopublic?synchronized?void?f(){g();}public??synchronized?void?g(){}}

实现重入的一种方法是为每个键设置一个计数器并注册处理程序线程。
持有线程可以重复重新进入,每次释放时计数器都会累加,计数器减1。
当计数器的值为0时,表示当前锁被释放。

当然,当你调用MutexFoo对象的f方法时,线程必须对该对象加锁两次。
如果Java设计锁是不可重入的,那么下面的代码就会导致锁。

class?ArrayBuilder{public?void?build(int[]?arr){?int?count?=?0;?for(int?i?=?0?;?i?<=?arr.length?-?1?;?i++,count++)?{?arr[i]?=?i;System.out.printf("count?=?%s\n",count);}0锁定约定

常见的锁定协议是将所有可变状态封装在对象内,然后使用对象内置的同步锁来访问状态代码为同步的可变块。
开发人员必须小心,因为如果某个地方缺少同步规则,整个锁定协议就会失败。

原子操作涉及的许多变量必须受到保护来自同一个内置锁例如,转账业务的account_A和account_B受到银行设施本身内置锁的保护。

并非所有数据都需要锁定。
假设转账前银行会根据单价和购买数量临时计算交易金额:

class?ArrayBuilder{public?void?build(int[]?arr){?int?count?=?0;for(int?i?=?0?;?i?<=?arr.length?-?1i++,count++)?{arr[i]?=?i;?System.out.printf("count?=?%s\n",count?}}}1

因为transform方法中添加了内置阻塞,所以多个线程必须以完全串行的方式操作(假设只有一个Bank对象)这里使用),整个代码的执行性能会很差,我们实际上注意到sum是1。
局部变量,它本身不会被任何线程分配,所以不需要将其添加到同步代码块中:

class?ArrayBuilder{void?build?(int[]?arr){int?count?=?0;?for(int?i?=?0?;?i?<=?arr.length?-?1?;?i++,count++)?{?arr[i]?=?i;System.out.printf("count?=?%s\n",count);?}}}2

这样,即使线程发现无法立即修改账户A和B的状态,仍然可以先计算出转账金额,而不是犯傻,等待其他线程执行完毕后再进行计算。
尤其是当一些局部变量需要进行多次操作时,细粒度的块粒度可以在性能和安全性之间找到很好的平衡。

如果死锁长期维持,性能问题就会变得非常显着。
因此,请确保不在同步代码块内执行严重耗时的操作。