本文转载自微信公众号「怀梦追码」,可以通过以下二维码关注。转载本文请联系怀梦追码公众号。
1. 同步访问共享数据
问题
并发程序要比单线程程序的设计更加复杂,并且失败难以重现。但是又无法避免采用多线程,因为采用多线程并发是能够从多核计算机获得最佳性能的一个有效途径。在并发时,如果涉及到可变数据的时候,就是我们需要着重去思考的地方,在面对可变数据的并发访问的时候,有哪些方式可以保证线程安全性?
答案
1.关键字synchronized:synchronized是保证线程安全的一大利器,它可以保证同一时刻,只有一个线程可以执行某个方法和修改某一个可变数据,但是仅仅将它理解成是互斥的也是不完全正确的,它主要有两种意义:
另外,java语言规范保证读写一个变量是原子的,除非这个变量是double或者long,即使没有在保证同步的情况下也是如此。
考虑到这样一个示例,线程通过轮询标志位而达到优雅的停止线程的功能,示例代码如下:
- private static boolean stopRequested;
- private static synchronized void requestStop() {
- stopRequested = true;
- }
- private static synchronized boolean stopRequested() {
- return stopRequested;
- }
- public static void main(String[] args) throws InterruptedException {
- Thread backgroundThread = new Thread(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!stopRequested()) {
- i++;
- }
- }
- });
- backgroundThread.start();
- TimeUnit.SECONDS.sleep(1);
- requestStop();
- }
可变数据也就是状态变量stopRequested,被同步方法修改,这里也就是保证stopRequested被修改后,能够被其他线程立即可见。
2.关键字volatile:volatile最重要的功能是能够保证数据可见性,当一个线程修改可变数据后,另一个线程会立刻知道最新的数据。在上面的例子中,因为stopRequested变量的读写本身就是原子的,因此利用synchronized只是利用到它的数据可见性,但是由于synchronized会加锁,如果想性能更优的话,上面的例子就可以采用volatile进行修改:
- private static volatile boolean stopRequested;
- public static void main(String[] args) throws InterruptedException {
- Thread backgroundThread = new Thread(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!stopRequested) {
- i++;
- }
- }
- });
- backgroundThread.start();
- TimeUnit.SECONDS.sleep(1);
- stopRequested = true;
- }
但是需要注意到volatile并不能保证原子性,例如下面的例子:
- private static volatile int nextSerialNumber = 0;
- public static int generateSerialNumber() {
- return nextSerialNumber++;
- }
尽管使用了volatile,但是由于++运算符不是原子的,因此在多线程的时候会出错。++运算符执行两项操作:1、读取值;2、写回新值(相当于原值+1)。如果第二个线程在第一个线程读取旧值和写会新值的时候读取了这个域,就会产生错误,他们会得到相同的SerialNumber。这个时候就需要使用synchorized来使得线程间互斥访问,从而保证原子性。
总结
解决这一问题的最好办法其实是尽量避免在线程间共享可变数据,将可变数据限制在单线程中。如果想要多个线程共享可变数据,那么读写都需要进行同步。
2.慎用创建线程的方式
问题
由于并发程序很容易出现线程安全的问题,并且线程的管理也是件很复杂的事情,所以当创建一个线程时,不要通过Thread的方式手动创建,可以使用Executor框架进行管理。Executor的优点是什么?
答案
结论
在涉及到多线程程序时,不要使用Thread的方式创建线程,应该使用executor来管理和创建线程,它最大的好处在于工作单元(线程)和任务之间的解耦。
3.优先使用并发工具
问题
高并发程序既很难保证线程安全的问题,而且一旦出现问题之后,也很难排错和分析出来原因。而j.u.c包中提供了很多线程安全的工具,应该在实际开发中多使用这些性能已经得到了验证的工具,这使得我们的开发能够十分方便又能保证我们代码的稳定性。常用的并发工具有哪些?
答案
j.u.c包下的并发工具分为三类:1.负责管理线程的executor框架;2.并发集合;3.同步器。其中,负责管理线程的executor在第68条已经说过,不再单独描述。
结论
j.u.c包下跟我们提供了多种保证线程安全的数据结构,在实际开发中应该使用这些性能和安全性已经得到保证的工具,而不是重复造轮子,并且很难保证安全性。比如,在之前的代码中“生产者-消费者”使用wait和notify的方式去实现,代码就很难维护,如果使用可阻塞操作的BlockingQueue代码更加简洁,逻辑也更加清晰。
4.线程安全文档化
问题
有这样几种错误的说法:
这是两种普遍错误的观点,事实上,线程安全性是有多种级别的,那么,应该如何建立线程安全性的文档?
答案
- Collection c = Collections.synchronizedCollection(myCollection);
- synchronized(c) {
- Iterator i = c.iterator(); // Must be in the synchronized block
- while (i.hasNext())
- foo(i.next());
- }
- private final Object lock = new Object();
- public void foo(){
- synchronized(lock){
- ...
- }
- }
这时,私有锁对象只能被当前类内部访问到,并不能被外部访问到,因此不可能妨碍到当前类的同步,就可以避免“拒绝服务攻击”。但是,这种方式只适合“无条件线程安全”级别,并不能适用于“有条件性的线程安全”的级别,有条件的线程安全级别,必须在文档中说明,在调用方法时应该获得哪把锁。
总结
每个类都应该利用严谨的说明或者线程安全注解,清楚地在文档中说明它的线程安全属性。有条件的线程安全类,应该说明哪些方法需要同步访问,以及获得哪把锁。无条件的线程安全类可以采用私有锁对象来防止“拒绝服务攻击”。涉及到线程安全的问题,应该严格按照规范编写文档。
5.慎用延迟初始化
延迟初始化(lazy initialization)是延迟到需要域的值时才将它初始化的这种行为。如果永远不需要这个值,这个域就永远不会被初始化。这种方法既适用于静态域,也适用于实例域。和大多数优化一样,不成熟的优化是大部分错误的源头。那么针对线程安全的延迟初始化有哪些可靠的方式?
下面是正常初始化实例域的方式,但是要注意采用了final修饰符:
- private final FildType field= computeFieldValue();
现在要对这个实例域进行延迟初始化,有这样几种方式:
1.同步方法:在实例化域值得时候,可以使用同步方法从而保证线程安全性,如:
- private FieldType field;
- synchronized FieldType getField(){
- if(field == null){
- field = computeFieldValues();
- }
- return field;
- }
2.静态内部类:为了减小上面这种方式的同步访问成本,可以采用静态内部类的方式,被称之为lazy initialization holder class 模式。在jvm的优化下,这种方式不仅可以达到延迟初始化的效果,也能保证线程安全。示例代码为:
- private static class FieldHolder{
- static final FieldType field = computeFieldValue();
- }
- static FieldType getField(){
- return FieldType.field;
- }
3.双重检测:这种模式避免了在初始化之后,再次访问这个域时的锁定开销(在普通的方法里面,会使用synchronized对方法进行同步,每次访问方法的时候都要进行锁定)。这种模式的思想是:两次检查域的值,第一次检查时不锁定,看看其是否初始化;第二次检查时锁定。只用当第二次检查时,表明其没有被初始化,才会调用computeFieldValue方法对其进行初始化。如果已经被初始化了,就不会锁定了,另外该域被声明为volatile非常重要,示例代码为:
- private volatile FieldType field;
- public FieldType getField() {
- FieldType result = field;
- if (result == null) {
- synchronized (this) {
- result = field;
- if (result == null) {
- field = result = computeFieldValue();
- }
- }
- }
- return result;
- }
结论
大多数正常的初始化都要优于延迟初始化。如果非要进行延迟初始化的话,针对实例域采用双重检测方式,针对静态域,可以利用静态内部类的第一次访问才进行初始化的特性,使用静态内部类来完成延迟初始化。
6.不要依赖线程调度器
当有多个线程运行时,由线程调度器决定哪些线程将会运行,分配CPU时间片。但是,在大多数系统采用的调度策略都是不太相同的,因此,任何依赖于线程调度器来达到程序性能和正确性的并发程序都是不安全和不可移植的。那么,在编写可移植的,健壮性强的并发程序有哪些好的方法?
千万不能让程序依赖线程调度器,这样会失去健壮性和可移植性。而Thread.yield和线程优先级这些特性,是最不具有可移植性,程序中不应该使用它们。
7.避免使用线程组
除了线程、锁和监视器外,线程系统还提供了另外一个抽象单元:线程组。线程组的设计初衷是作为隔离applet的机制,达到安全性。但是,实际上并未达到所期待的安全性,甚至都差到在JAVA安全模型上都未提及。除了安全性的糟点外,还有哪些缺陷?
除了安全性没有达到预期外,可用的基本功能很少;
ThreadGroup的API非常脆弱;
线程组并没有提供太多有用的功能,而且它们提供的许多功能还都是有缺陷的。当管理线程或处理线程组逻辑时,应该考虑使用executor。
数据中心融合的出现是为了克服传统基础设施和存储的局限性,其目的是寻找更好地...
随着手机行业的发展,手机的品牌不断变少,一场腥风血雨竞争后,留下了这些优秀...
在日常生活中,使用手机的时候,不管你用的是安卓手机的华为,小米,oppo,还是...
本文转载自微信公众号「嵌入式从0到1」,可以通过以下二维码关注。转载本文请联...
本篇文章我们来聊一聊 DHCP 协议。在聊之前,先想象一个场景。 你现在站在地铁上...
有一天你腻了,想换到另一个平台,一般在权衡售价和手机参数后差不多就能换了。...
尽管2021年对于许多组织来说充满不确定性,但随着组织的IT领导者为未来工作做好...
人工智能的概念第一次被提出,是在20世纪50年代,距今已六十余年的时间。然而直...
9月1日,交管12123小程序正式入驻支付宝, 这是12123迎来的首个站外官方服务渠道...
近日,陕西省通信管理局对中国移动西安分公司无正当理由拒绝对用户提供携号转网...