体系全能软件测试工程师含源码
Java同步工具和复合类的线程安全性分析
基于条件的同步策略
不变性条件取决于类的语义。例如,counter类的counter属性设置为Integer类型。虽然它的域值在整数之间。最小值和整数。MAX_VALUE,其值必须是非负的。也就是说,随着计数的进行,计算器> = 0始终成立。
除了不变性条件,有些运算还需要通过后验条件来判断状态变化是否有效。例如,当计数器计数到17时,它的下一个状态只能是18。其实这涉及到“读-改-写”三个连续的步骤到原始状态,比如自我增量++等等。“无记忆”的状态不需要后验条件,比如定时测得的温度。
先验条件可能是一个更受关注的问题,因为“先判断后执行”的逻辑无处不在。例如,当对列表执行移除操作时,必须首先确保列表不为空,否则应该引发异常。
在并发环境中,这些条件可能会随着其他线程的修改而被扭曲。
状态发布和所有权
在许多情况下,所有权和封装是相互关联的。例如,一个对象通过private关键字封装其状态,这意味着该实例拥有该状态的独占所有权(所有权意味着控制权)。否则,状态称为已发布。发布的实例状态可能在任何地方被修改,因此它们在多线程环境中也有风险。
类和元素通常表现为“所有权”分离的形式。例如,如果一个列表被声明为final,客户端可以自由地修改其元素的状态,尽管它不能修改自己的引用。事实上,这些发布的元素必须安全地共享。这需要以下要素:
是不变事实的一个例子。
线程安全的一个实例。
被锁保护着。
实例闭包
大多数对象是复合对象,或者这些状态也是对象。复合类的线程安全分析可以大致分为两类:
如果这些状态线程不安全,我们应该如何安全地使用复合类?
即使所有状态都是线程安全的,是否可以推断复合类是线程安全的?还是复合类需要额外的同步策略?
对于第一个问题,请参见下面的银行代码,它模拟了一个货币转账业务:
银行类别{
私有整数amount _ A = 100
私有整数amount _ B = 50
公共同步作废交易(整数金额){
var log _ 0 = amount _ A+amount _ B;
amount_A +=金额;
amount_B -=金额;
var log _ 1 = amount _ A+amount _ B;
断言log _ 0 = = log _ 1;
}
}
复制代码
尽管amount_A和amount_B不像普通整数类型那样是线程安全的,但它们具有线程安全的语义:
他们是私人会员,所以不存在不小心被分享的问题。
它们唯一与外界交互的transaction()方法受到锁的保护。
也可以理解为Bank是一个为两个整数状态提供线程安全的容器。这里,同步策略是由同步的内置锁实现的。
编译器将在同步代码区的前后插入monitorenter和monitorexit字节码,以指示进入/退出同步代码块。Java的内置锁也叫monitor lock,或monitor。
至于第二个问题,答案是:要看情况,具体来说,是不是有不变性条件。这里是指账户A和账户B的余额之和在转账过程中应该保持不变。如果使用原子类型来保护amount_A和amount_B的状态,是否可以移除transaction()方法上的内置锁?
UnsafeBank类{
private final atomic integer amount _ A = new atomic integer(100);
private final atomic integer amount _ B = new atomic integer(50);
公共作废交易(整数){
amount_A.set(amount_A.get() -金额);
amount_B.set(amount_B.get() +金额);
}
}
复制代码
transaction()方法现在失去了锁的保护。这样,在一个线程A执行事务的同时,另一个线程B也可能“趁机”修改amount_B的账户——这个时间发生在线程A执行amount_B.get()之后,amount_B.set()之前。最终,线程B的修改将被覆盖并丢失。在其看来,虽然两种状态都是原子变量,但不变性条件还是被破坏了。
由此,我们得到一个结论——即使所有可变状态都是原子的,我们可能仍然需要进一步考虑封装类级别的同步策略。最简单、最直接的方法是找出封装类中的所有复合操作:
读-改-写同一个变量(重复)。
修改受不变条件约束的多个变量。
正确展开同步策略。
大多数情况下,我们无法通过直接修改类源代码来补充同步策略。例如,公共列表接口不保证下面的实现是线程安全的,但是我们可以以类似代理的方式将线程安全委托给第三方。例如:
类ThreadSafeArrayList {
私有最终列表列表;
public ThreadSafeArrayList(List l){ List = l;}
//添加新方法
public synchronized boolean putIfAbsent(整数a){
if(list.contains(a)) {
list . add(a);
返回true
}
返回false
}
//代理添加方法,其他省略。
公共同步布尔加法(整数a) {
返回list . add(a);
}
// ...
}
复制代码
事实上,Java类库已经有了相应的线程安全类。一般来说,我们应该优先重用这些现有的类。在下面的代码块中,我们使用Collection.synchronizedList工厂方法来创建一个线程安全的List对象,这样看起来我们只需要锁定新扩展的putIfAbsent()方法。
类ThreadUnSafeArrayList {
private final List List = collections . synchronized List(new ArrayList());
//添加新方法
public synchronized boolean putIfAbsent(整数a){
if(list.contains(a)) {
list . add(a);
返回true
}
返回false
}
public boolean add(Integer a){ return list . add(a);}
// ...
}
复制代码
但是,上面的代码是错误的。为什么?问题是我们使用了错误的同步锁。调用add方法时,使用list对象的内置锁;但是,在调用putIfAbsent方法时,我们使用ThreadUnsafeArrayList对象的内置锁。这意味着putIfAbsent方法对于其他方法来说不是原子的,因此不能保证当一个线程执行putIfAbsent方法时,其他线程会通过调用其他方法来修改列表。
因此,如果要正确执行这个方法,我们必须将其锁定在正确的位置。
类ThreadUnSafeArrayList {
private final List List = collections . synchronized List(new ArrayList());
public boolean putIfAbsent(整数a){
同步(列表){
if(list.contains(a)) {
list . add(a);
返回true
}
返回false
}
}
}
复制代码
同步容器
同步容器是安全的,但是在某些情况下,客户端仍然需要被锁定。常见操作,例如:
迭代;
跳转(比如寻找下一个元素);
条件,如“如果没有XX运算”(常见的复合运算);
复合操作不受同步容器的保护。
这里,两个线程T1和T2将以不可预知的顺序执行两个代码块,它们负责删除和读取列表中的最后一个元素。我们在这里使用库中的同步列表,所以我们可以确保size()、remove()和get()方法都是原子的。但是当程序按照x1,y1,x2,y2的顺序执行时,主程序最终还是会抛出IndexOutOfBoundsException异常。
DemoOfConcurrentFail类{
public final List List = collections . synchronized List(new ArrayList());
{
Collections.addAll(list,1,2,3,4,5);
}
公共静态void main(String[] args) {
var testList = new DemoOfConcurrentFail()。列表;
可运行的t1 =()--> {
var last = test list . size()-1;// x1
testList.remove(最后一个);// x2
};
可运行的T2 =()--> {
var last = test list . size()-1;// y1
var r = test list . get(last);// y2
system . out . println(r);
};
新线程(t1)。start();
新线程(t2)。start();
}
}
复制代码
原因是两个线程T1和T2执行的复合操作没有锁保护(实际上是前面银行转账例子中犯的错误)。所以正确的做法是整体锁定复合操作。例如:
var mutex = new Object();
可运行的t1 =()--> {
同步(互斥){
var last = test list . size()-1;// x1
testList.remove(最后一个);// x2
}
};
可运行的T2 =()--> {
同步(互斥){
var last = test list . size()-1;// y1
var r = test list . get(last);// y2
system . out . println(r);
}
};
// ...
复制代码
同步容器的迭代问题
类似的问题在迭代运算中依然存在。无论是直接for循环还是for-each循环,迭代器都是用来遍历容器的。迭代器本身就是一个判断(hasNext)然后读取(Next)的复合过程。Java同步容器的迭代处理是:如果一个线程在迭代的过程中发现容器被修改了,就会立即失败(也叫及时失败),抛出ConcurrentModificationException异常。
//可能需要运行多次才能引发ConcurrentModicizationException。
可运行的t1 =()--> {
//删除中间的元素
int mid = test list . size()/2;
test list . remove(mid);
};
可运行的T2 =()--> {
for(var item : testList){
system . out . println(item);
}
};
新线程(t1)。start();
新线程(t2)。start();
复制代码
同样,如果我们想不受干扰地迭代容器元素,我们必须锁定for循环的外部,但这可能不是一个好主意。如果容器的大小非常大,或者每个元素的处理时间非常长,那么等待容器执行短作业的其他线程就会卡在长时间的等待中,从而带来活动问题。
一个可行的方法是实现读写分离——一旦有写操作,就重新复制一个新的容器副本,而在此期间所有的读操作仍在原容器中进行,实现“读写共享”。当读操作远远多于写操作时,这种方法无疑可以大大提高程序的吞吐量。请参阅以下文章中的并发容器CopyOnWriteArrayList。
当心隐式迭代的操作
触发迭代的不仅仅是显式for循环。比如容器的toString方法调用底层的StringBuilder.append()方法依次拼接每个元素的字符串。另外,包含equals、containsAll、removeAll、retainAll,甚至是以容器本身为参数的构造函数,都隐含着容器的迭代过程。这些间接迭代错误可能都会引发ConcurrentModificationException异常。
并发容器
考虑到重量级锁对性能的影响,Java随后提供了各种并发容器来提高同步容器的性能。同步容器完全序列化所有操作。当锁竞争特别激烈的时候,程序的吞吐量会大大降低。因此,使用并发容器代替同步容器在大多数情况下是“免费的午餐”。
实现队列
ConcurrentHashMap使用更小的阻塞粒度来换取更大的共享。这种阻塞机制称为锁剥离。简单来说,每个桶都由一个单独的锁保护,两个运行不同桶的线程不需要互相等待。优点是在高并发环境下,ConcurrentHashMap带来了更大的吞吐量,但问题是阻塞粒度的降低削弱了容器的一致性语义,或者说弱一致。
比如需要在整个地图上计算的size()和isEmpty()方法,弱一致性会使这些方法的计算结果成为过期值。这是一种权衡,因为在并发环境下,这两种方法的作用不大,因为它们的返回值总是在变化的。因此,这些操作的要求被削弱,以换取其他更重要的性能优化,如get、put、cotainsKey、remove等。
所以,除非某些严谨的业务不能容忍弱一致性,否则并发HashMap是比同步HashMap更好的选择。
copy onwriterarraylist
当读操作远远多于写操作时,该工具可以提供更好的并发性能,并且在迭代过程中不需要锁定或复制容器。当修改发生时,容器将创建并重新发布容器的新副本。在创建新副本之前,所有读取操作仍然受制于旧容器,因此这不会引发concurrentmodificationexception问题。
相反,如果频繁调用add、remove、set等方法,容器的吞吐量会大大降低,因为这些操作需要反复调用系统的copy方法来复制底层数组(这也是为什么不设计“CopyOnWriteLinkedList”的原因,因为复制的效率会更低)。同时,写时复制的特性使得CopyOnWriteArrayList具有弱一致性。
阻塞队列&生产者-消费者模型
阻塞队列,简单来说就是当队列为空时,取操作会进入阻塞状态;当队列已满时,put操作也将被阻塞。阻塞队列也可以分为有界队列和无界队列。无限队列永远不会满,所以执行put方法永远不会进入阻塞状态。但是,如果生产者的执行效率远远超过消费者,无界队列的无限膨胀最终会耗尽内存。有界队列可以确保当队列已满时,生产者将被put阻塞,这样,消费者就可以赶上工作进度。
生产者-消费者模型可以通过阻塞队列来实现。最常见的生产者-消费者模型是线程池和工作队列的组合。这种模式将“发布任务”和“接收任务”解耦,最大的便利是简化了复杂的负载管理,因为生产者和消费者的执行速度并不总是匹配的。同时,生产者和消费者的角色是相对的。例如,装配线中游的组件既充当上游消费者,又充当下游生产者。
Java库已经包含了各种关于阻塞队列的实现,它保证了put和take操作是线程安全的。
LinkedBlockingQueue和ArrayBlockingQueue:两者的区别可以参考Link和Array。请参见ArrayBlockingQueue和LinkedBlockingQueue。两者都是FIFO的队列。
PriorityBlockingQueue:优先级队列,当我们要按一定顺序处理任务时,比FIFO队列更实用。
SynchronousQueue:翻译为同步阻塞队列。这个队列实际上没有缓存空间,但是维护一组可用的线程。当队列收到消息时,它可以立即分配一个线程来处理它。但是如果没有多余的工作线程,那么调用put或take将会立即阻塞。因此,只有当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。
下面的代码块是一个由SynchronousQueue实现的简单演示,每个线程都将抢先使用消息。
var chan = new synchronous queue();
var worker =新线程(()--> {
while(true){
尝试{
final var x = chan . take();
System.out.println("t1消费:"+x);
} catch(interrupted exception e){ e . printstacktrace();}
}
});
var worker2 =新线程(()--> {
while(true){
尝试{
final var x = chan . take();
system . out . println(" T2 consume:"+x);
} catch(interrupted exception e){ e . printstacktrace();}
}
});
worker . start();
worker 2 . start();
for(var I = 0;i < 10i++)chan . put(I);
复制代码
从所有权的角度来看,生产者-消费者模型和阻塞队列共同促进串行线程关闭。一个封闭的线程对象只能被单个对象拥有,但可以通过在执行结束时发布该对象来“转移”(即不会再被使用)。
阻塞队列简化了传输逻辑。此外,还可以通过ConcurrentMap的原子方法remove或者AtomicReference的compareAndSet (CAS机制)实现安全的串行线程关闭。
德克和工作盗窃。
Java 6之后增加了新的容器类型——dequee和BlockDeque,是Queue和BlockingQueue的扩展。Deque实现了队列头和队列尾的高效插入和移除,包括ArrayDeque和LinkedBlockingDeque。
德雀适合另一种工作模式——偷工减料。例如,当一个工作线程完成清空自己的任务队列时,它可以从其他繁忙工作线程的任务队列尾部获取队列。这种模式比生产者-消费者模式更具可伸缩性,因为工作线程不会在单个共享任务队列上竞争。
体系课全能软件测试工程师
download:链接:https://pan.baidu.com/s/1xIcSaUaotg3hSSZHCQrU6Q?pwd=0wa8
提取码:0wa8
--来自百度网盘超级会员V5的分享