文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

关于使用线程需要注意地方,你都知道吗?

2024-12-10 16:05

关注

本文转载自微信公众号「怀梦追码」,可以通过以下二维码关注。转载本文请联系怀梦追码公众号。

 1. 同步访问共享数据

问题

并发程序要比单线程程序的设计更加复杂,并且失败难以重现。但是又无法避免采用多线程,因为采用多线程并发是能够从多核计算机获得最佳性能的一个有效途径。在并发时,如果涉及到可变数据的时候,就是我们需要着重去思考的地方,在面对可变数据的并发访问的时候,有哪些方式可以保证线程安全性?

答案

关键字synchronized:synchronized是保证线程安全的一大利器,它可以保证同一时刻,只有一个线程可以执行某个方法和修改某一个可变数据,但是仅仅将它理解成是互斥的也是不完全正确的,它主要有两种意义:

另外,java语言规范保证读写一个变量是原子的,除非这个变量是double或者long,即使没有在保证同步的情况下也是如此。

考虑到这样一个示例,线程通过轮询标志位而达到优雅的停止线程的功能,示例代码如下:

  1. private static boolean stopRequested; 
  2.     private static synchronized void requestStop() { 
  3.         stopRequested = true
  4.     } 
  5.     private static synchronized boolean stopRequested() { 
  6.         return stopRequested; 
  7.     } 
  8.     public static void main(String[] args) throws InterruptedException { 
  9.         Thread backgroundThread = new Thread(new Runnable() { 
  10.             @Override 
  11.             public void run() { 
  12.                 int i = 0; 
  13.                 while (!stopRequested()) { 
  14.                     i++; 
  15.                 } 
  16.             } 
  17.         }); 
  18.         backgroundThread.start(); 
  19.         TimeUnit.SECONDS.sleep(1); 
  20.         requestStop(); 
  21.     } 

可变数据也就是状态变量stopRequested,被同步方法修改,这里也就是保证stopRequested被修改后,能够被其他线程立即可见。

关键字volatile:volatile最重要的功能是能够保证数据可见性,当一个线程修改可变数据后,另一个线程会立刻知道最新的数据。在上面的例子中,因为stopRequested变量的读写本身就是原子的,因此利用synchronized只是利用到它的数据可见性,但是由于synchronized会加锁,如果想性能更优的话,上面的例子就可以采用volatile进行修改:

  1. private static volatile boolean stopRequested;  
  2. public static void main(String[] args) throws InterruptedException { 
  3.     Thread backgroundThread = new Thread(new Runnable() { 
  4.         @Override 
  5.         public void run() { 
  6.             int i = 0; 
  7.             while (!stopRequested) { 
  8.                 i++; 
  9.             } 
  10.         } 
  11.     }); 
  12.     backgroundThread.start(); 
  13.     TimeUnit.SECONDS.sleep(1); 
  14.     stopRequested = true

但是需要注意到volatile并不能保证原子性,例如下面的例子:

  1. private static volatile int nextSerialNumber = 0;  
  2.  public static int generateSerialNumber() { 
  3.      return nextSerialNumber++; 
  4.  } 

尽管使用了volatile,但是由于++运算符不是原子的,因此在多线程的时候会出错。++运算符执行两项操作:1、读取值;2、写回新值(相当于原值+1)。如果第二个线程在第一个线程读取旧值和写会新值的时候读取了这个域,就会产生错误,他们会得到相同的SerialNumber。这个时候就需要使用synchorized来使得线程间互斥访问,从而保证原子性。

总结

解决这一问题的最好办法其实是尽量避免在线程间共享可变数据,将可变数据限制在单线程中。如果想要多个线程共享可变数据,那么读写都需要进行同步。

2.慎用创建线程的方式

问题

由于并发程序很容易出现线程安全的问题,并且线程的管理也是件很复杂的事情,所以当创建一个线程时,不要通过Thread的方式手动创建,可以使用Executor框架进行管理。Executor的优点是什么?

答案

  1. 等待任务执行完成的方式多样:当前线程可以等待提交到executor中的线程集合全部执行完成(invokeAll()或invokeAny()),也可以优雅的等待结束(awaitTermination()),也可以在任务完成时逐个获取这些任务的结果(利用ExecutorCompletionService)等等;
  2. 创建多种类型的线程池:可以创建单个线程、固定的多个线程以及线程个数可变的线程池,也可以通过ThreadPoolExecutor类创建适合应用场景的线程池;
  3. 线程和执行间的解耦:使用executor最大的好处在于将线程执行机制和任务解耦开,之前的Thread类既充当了工作单元又是执行机制,更好管理和使用起来更加安全可靠。

结论

在涉及到多线程程序时,不要使用Thread的方式创建线程,应该使用executor来管理和创建线程,它最大的好处在于工作单元(线程)和任务之间的解耦。

3.优先使用并发工具

问题

高并发程序既很难保证线程安全的问题,而且一旦出现问题之后,也很难排错和分析出来原因。而j.u.c包中提供了很多线程安全的工具,应该在实际开发中多使用这些性能已经得到了验证的工具,这使得我们的开发能够十分方便又能保证我们代码的稳定性。常用的并发工具有哪些?

答案

j.u.c包下的并发工具分为三类:1.负责管理线程的executor框架;2.并发集合;3.同步器。其中,负责管理线程的executor在第68条已经说过,不再单独描述。

结论

j.u.c包下跟我们提供了多种保证线程安全的数据结构,在实际开发中应该使用这些性能和安全性已经得到保证的工具,而不是重复造轮子,并且很难保证安全性。比如,在之前的代码中“生产者-消费者”使用wait和notify的方式去实现,代码就很难维护,如果使用可阻塞操作的BlockingQueue代码更加简洁,逻辑也更加清晰。

4.线程安全文档化

问题

有这样几种错误的说法:

这是两种普遍错误的观点,事实上,线程安全性是有多种级别的,那么,应该如何建立线程安全性的文档?

  1. 通过查看文档是否出现synchronized修饰符,来判断当前方法是否是安全的。这种说话的错误在于,synchronized并不会通过javadoc输出,成为api文档的一部分,这是因为synchronized是方法具体的实现细节,并不属于导出API和外界模块通信的一部分;
  2. “只要是加了synchronized关键字的方法或者代码块就一定是线程安全的,而没有加这个关键字的代码就不是线程安全的”。这种观点将synchronized于线程安全等同起来,并且认为线程安全只有两种极端的情况,要么是线程安全的,要么是线程不安全的。

答案

  1. Collection c = Collections.synchronizedCollection(myCollection); 
  2. synchronized(c) { 
  3.     Iterator i = c.iterator(); // Must be in the synchronized block 
  4.     while (i.hasNext()) 
  5.         foo(i.next()); 
  1. 线程的安全性级别:
  2. 在文档中描述有条件的线程安全类要特别小心,必须指明哪个调用方法需要外部同步,并且需要获得哪一把锁;
  3. 如果使用类使用的是“一个可公有访问的锁对象”的话,很可能被其他线程超时地保持公有可访问锁,而造成当前线程一直无法获得锁对象,这种行为被称为“拒绝服务攻击”,为了避免这种攻击可以采用 私有锁对象,例如:
  1. private final Object lock = new Object(); 
  2. public void foo(){ 
  3.     synchronized(lock){ 
  4.         ... 
  5.     } 

这时,私有锁对象只能被当前类内部访问到,并不能被外部访问到,因此不可能妨碍到当前类的同步,就可以避免“拒绝服务攻击”。但是,这种方式只适合“无条件线程安全”级别,并不能适用于“有条件性的线程安全”的级别,有条件的线程安全级别,必须在文档中说明,在调用方法时应该获得哪把锁。

总结

每个类都应该利用严谨的说明或者线程安全注解,清楚地在文档中说明它的线程安全属性。有条件的线程安全类,应该说明哪些方法需要同步访问,以及获得哪把锁。无条件的线程安全类可以采用私有锁对象来防止“拒绝服务攻击”。涉及到线程安全的问题,应该严格按照规范编写文档。

5.慎用延迟初始化

延迟初始化(lazy initialization)是延迟到需要域的值时才将它初始化的这种行为。如果永远不需要这个值,这个域就永远不会被初始化。这种方法既适用于静态域,也适用于实例域。和大多数优化一样,不成熟的优化是大部分错误的源头。那么针对线程安全的延迟初始化有哪些可靠的方式?

下面是正常初始化实例域的方式,但是要注意采用了final修饰符:

  1. private final FildType field= computeFieldValue(); 

现在要对这个实例域进行延迟初始化,有这样几种方式:

同步方法:在实例化域值得时候,可以使用同步方法从而保证线程安全性,如:

  1. private FieldType field; 
  2. synchronized FieldType getField(){ 
  3.     if(field == null){ 
  4.         field = computeFieldValues(); 
  5.     } 
  6.     return field; 

静态内部类:为了减小上面这种方式的同步访问成本,可以采用静态内部类的方式,被称之为lazy initialization holder class 模式。在jvm的优化下,这种方式不仅可以达到延迟初始化的效果,也能保证线程安全。示例代码为:

  1. private static class FieldHolder{ 
  2.     static final FieldType field = computeFieldValue(); 
  3. static FieldType getField(){ 
  4.     return FieldType.field; 

双重检测:这种模式避免了在初始化之后,再次访问这个域时的锁定开销(在普通的方法里面,会使用synchronized对方法进行同步,每次访问方法的时候都要进行锁定)。这种模式的思想是:两次检查域的值,第一次检查时不锁定,看看其是否初始化;第二次检查时锁定。只用当第二次检查时,表明其没有被初始化,才会调用computeFieldValue方法对其进行初始化。如果已经被初始化了,就不会锁定了,另外该域被声明为volatile非常重要,示例代码为:

  1. private volatile FieldType field; 
  2. public FieldType getField() { 
  3.     FieldType result = field; 
  4.     if (result == null) { 
  5.         synchronized (this) { 
  6.             result = field; 
  7.             if (result == null) { 
  8.                 field = result = computeFieldValue(); 
  9.             } 
  10.         } 
  11.     } 
  12.     return result; 

结论

大多数正常的初始化都要优于延迟初始化。如果非要进行延迟初始化的话,针对实例域采用双重检测方式,针对静态域,可以利用静态内部类的第一次访问才进行初始化的特性,使用静态内部类来完成延迟初始化。

6.不要依赖线程调度器

当有多个线程运行时,由线程调度器决定哪些线程将会运行,分配CPU时间片。但是,在大多数系统采用的调度策略都是不太相同的,因此,任何依赖于线程调度器来达到程序性能和正确性的并发程序都是不安全和不可移植的。那么,在编写可移植的,健壮性强的并发程序有哪些好的方法?

  1. 最好的方式是,保证可运行的线程尽可能少,或不明显高于处理器的数量。如果,可运行的线程足够少,对线程调度器而言就不需要“纠结”为哪个线程分配时间片,只需要让多核处理器处理这些线程就好了。从侧面来说,就降低了对线程调度器的调度策略的依赖。那么,保证尽可能少的线程数唯一的方法就是,让每个线程都做有意义任务,从整体而言,就会降低总线程的个数;
  2. 当程序不正确的时候,是因为线程无法获得足够的时间片的话,不要企图使用Thread.yield的方式,让其他线程让出时间片,来满足自身的需求。这是因为,不同的JVM上对Thread.yield语义的是不相同的,这样就失去了可移值性。另外,在测试期间,使用Thread.yield人为地来增加线程并发性,应该由Thread.sleep(1)来代替Thread.yield;
  3. 千万不要企图通过调整线程优先级来达到程序的正确性,线程的优先级是最不可移植的特性。

千万不能让程序依赖线程调度器,这样会失去健壮性和可移植性。而Thread.yield和线程优先级这些特性,是最不具有可移植性,程序中不应该使用它们。

7.避免使用线程组

除了线程、锁和监视器外,线程系统还提供了另外一个抽象单元:线程组。线程组的设计初衷是作为隔离applet的机制,达到安全性。但是,实际上并未达到所期待的安全性,甚至都差到在JAVA安全模型上都未提及。除了安全性的糟点外,还有哪些缺陷?

除了安全性没有达到预期外,可用的基本功能很少;

ThreadGroup的API非常脆弱;

线程组并没有提供太多有用的功能,而且它们提供的许多功能还都是有缺陷的。当管理线程或处理线程组逻辑时,应该考虑使用executor。

 

来源: 怀梦追码内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯