Java 多线程 死锁 隐性死锁 数据竞争 恶性数据竞争 错误解决深入分析 全方向举例

简介: 在几乎所有编程语言中,由于多线程引发的错误都有着难以再现的特点,程序的死锁或其它多线程错误可能只在某些特殊的情形下才出现,或在不同的VM上运行同一个程序时错误表现不同。

在几乎所有编程语言中,由于多线程引发的错误都有着难以再现的特点,程序的死锁或其它多线程错误可能只在某些特殊的情形下才出现,或在不同的VM上运行同一个程序时错误表现不同。因此,在编写多线程程序时,事先认识和防范可能出现的错误特别重要。无论是客户端还是服务器端多线程Java程序,最常见的多线程问题包括死锁、隐性死锁和数据竞争。

Java线程死锁如何避免这一悲剧  Java线程死锁需要如何解决,这个问题一直在我们不断的使用中需要只有不断的关键。不幸的是,使用上锁会带来其他问题。让我们来看一些常见问题以及相应的解决方法:


死锁                                                                                                                            

  死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

  导致死锁的根源在于不适当地运用synchronized关键词来管理线程对特定对象的访问。synchronized关键词的作用是,确保在某个时刻只有一个线程被允许执行特定的代码块,因此,被允许执行的线程首先必须拥有对变量或对象的排他性的访问权。当线程访问对象时,线程会给对象加锁,而这个锁导致其它也想访问同一对象的线程被阻塞,直至第一个线程释放它加在对象上的锁。

  由于这个原因,在使用synchronized关键词时,很容易出现两个线程互相等待对方做出某个动作的情形。代码一是一个导致死锁的简单例子。


1class Deadlocker {   
2 int field_1;   
3 private Object lock_1 = new int[1];   
4 int field_2;   
5 private Object lock_2 = new int[1];   
6  
7 public void method1(int value) {   
8  “synchronized” (lock_1) {   
9   “synchronized” (lock_2) {   
10    field_1 = 0; field_2 = 0;   
11   }   
12  }   
13 }   
14  
15 public void method2(int value) {   
16  “synchronized” (lock_2) {   
17   “synchronized” (lock_1) {   
18    field_1 = 0; field_2 = 0;   
19   }   
20  }   
21 }   
22 }   
23   
Java代码   
24 class Deadlocker {  
25 int field_1;  
26 private Object lock_1 = new int[1];  
27 int field_2;  
28 private Object lock_2 = new int[1];  
29  
30 public void method1(int value) {  
31  “synchronized” (lock_1) {  
32   “synchronized” (lock_2) {  
33    field_1 = 0; field_2 = 0;  
34   }  
35  }  
36 }  
37  
38 public void method2(int value) {  
39  “synchronized” (lock_2) {  
40   “synchronized” (lock_1) {  
41    field_1 = 0; field_2 = 0;  
42   }  
43  }  
44 }  
45 }  



参考代码一,考虑下面的过程:

 一个线程(ThreadA)调用method1()

ThreadAlock_1上同步,但允许被抢先执行。

 另一个线程(ThreadB)开始执行。

ThreadB调用method2()

ThreadB获得lock_2,继续执行,企图获得lock_1。但ThreadB不能获得lock_1,因为ThreadA占有lock_1

 现在,ThreadB阻塞,因为它在等待ThreadA释放lock_1

 现在轮到ThreadA继续执行。ThreadA试图获得lock_2,但不能成功,因为lock_2已经被ThreadB占有了。

ThreadAThreadB都被阻塞,程序死锁。

  当然,大多数的死锁不会这么显而易见,需要仔细分析代码才能看出,对于规模较大的多线程程序来说尤其如此。好的线程分析工具,例如JProbe Threadalyzer能够分析死锁并指出产生问题的代码位置。


 Java线程死锁是一个经典的多线程问题,因为不同的线程都在等待那些根本不可能被释放的锁,从而导致所有的工作都无法完成。假设有两个线程,分别代表两个饥饿的人,他们必须共享刀叉并轮流吃饭。他们都需要获得两个锁:共享刀和共享叉的锁。

  假如线程 “A”获得了刀,而线程“B”获得了叉。线程“A”就会进入阻塞状态来等待获得叉,而线程“B”则阻塞来等待“A”所拥有的刀。这只是人为设计的例子,但尽管在运行时很难探测到,这类情况却时常发生。虽然要探测或推敲各种情况是非常困难的,但只要按照下面几条规则去设计系统,就能够避免Java线程死锁问题:

  让所有的线程按照同样的顺序获得一组锁。这种方法消除了 X 和 Y 的拥有者分别等待对方的资源的问题。

  将多个锁组成一组并放到同一个锁下。前面Java线程死锁的例子中,可以创建一个银器对象的锁。于是在获得刀或叉之前都必须获得这个银器的锁。

  将那些不会阻塞的可获得资源用变量标志出来。当某个线程获得银器对象的锁时,就可以通过检查变量来判断是否整个银器集合中的对象锁都可获得。如果是,它就可以获得相关的锁,否则,就要释放掉银器这个锁并稍后再尝试。


  最重要的是,在编写代码前认真仔细地设计整个系统。多线程是困难的,在开始编程之前详细设计系统能够帮助你避免难以发现Java线程死锁的问题。我们再举一个例子


Volatile变量,volatile关键字是 Java 语言为优化编译器设计的。以下面的代码为例:

classVolatileTest {
  public void foo() {
  boolean flag = false;
  if(flag) {
  //this could happen

 一个优化的编译器可能会判断出if部分的语句永远不会被执行,就根本不会编译这部分的代码。如果这个类被多线程访问, flag被前面某个线程设置之后,在它被if语句测试之前,可以被其他线程重新设置。用volatile关键字来声明变量,就可以告诉编译器在编译的时候,不需要通过预测变量值来优化这部分的代码。

  无法访问的Java线程死锁有时候虽然获取对象锁没有问题,线程依然有可能进入阻塞状态。在 Java 编程中IO就是这类问题最好的例子。当线程因为对象内的IO调用而阻塞时,此对象应当仍能被其他线程访问。该对象通常有责任取消这个阻塞的IO操作。造成阻塞调用的线程常常会令同步任务失败。如果该对象的其他方法也是同步的,当线程被阻塞时,此对象也就相当于被冷冻住了。

  其他的线程由于不能获得对象的Java线程死锁,就不能给此对象发消息(例如,取消 IO 操作)。必须确保不在同步代码中包含那些阻塞调用,或确认在一个用同步阻塞代码的对象中存在非同步方法。尽管这种方法需要花费一些注意力来保证结果代码安全运行,但它允许在拥有对象的线程发生阻塞后,该对象仍能够响应其他线程。






隐性死锁                                                                                                                     

  隐性死锁由于不规范的编程方式引起,但不一定每次测试运行时都会出现程序死锁的情形。由于这个原因,一些隐性死锁可能要到应用正式发布之后才会被发现,因此它的危害性比普通死锁更大。下面介绍两种导致隐性死锁的情况:加锁次序和占有并等待。

  加锁次序

  当多个并发的线程分别试图同时占有两个锁时,会出现加锁次序冲突的情形。如果一个线程占有了另一个线程必需的锁,就有可能出现死锁。考虑下面的情形,ThreadAThreadB两个线程分别需要同时拥有lock_1lock_2两个锁,加锁过程可能如下:

ThreadA获得lock_1

ThreadA被抢占,VM调度程序转到ThreadB

ThreadB获得lock_2

ThreadB被抢占,VM调度程序转到ThreadA

ThreadA试图获得lock_2,但lock_2ThreadB占有,所以ThreadA阻塞;

 调度程序转到ThreadB

ThreadB试图获得lock_1,但lock_1ThreadA占有,所以ThreadB阻塞;

ThreadAThreadB死锁。

  必须指出的是,在代码丝毫不做变动的情况下,有些时候上述死锁过程不会出现,VM调度程序可能让其中一个线程同时获得lock_1lock_2两个锁,即线程获取两个锁的过程没有被中断。在这种情形下,常规的死锁检测很难确定错误所在。

  占有并等待

  如果一个线程获得了一个锁之后还要等待来自另一个线程的通知,可能出现另一种隐性死锁,考虑代码二。

47	public class queue {   
48	 static java.lang.Object queueLock_;   
49	 Producer producer_;   
50	 Consumer consumer_;   
51	  
52	 public class Producer {   
53	  void produce() {   
54	   while (!done) {   
55	    “synchronized” (queueLock_) {   
56	     produceItemAndAddItToQueue();   
57	     “synchronized” (consumer_) {   
58	      consumer_.notify();   
59	     }   
60	    }   
61	   }   
62	  }   
63	  
64	  public class Consumer {   
65	   consume() {   
66	    while (!done) {   
67	     “synchronized” (queueLock_) {   
68	      “synchronized” (consumer_) {   
69	       consumer_.wait();   
70	      }   
71	      removeItemFromQueueAndProcessIt();   
72	     }   
73	    }   
74	   }   
75	  }   
76	 }   
77	}   
78	   
Java代码   
79	public class queue {  
80	 static java.lang.Object queueLock_;  
81	 Producer producer_;  
82	 Consumer consumer_;  
83	  
84	 public class Producer {  
85	  void produce() {  
86	   while (!done) {  
87	    “synchronized” (queueLock_) {  
88	     produceItemAndAddItToQueue();  
89	     “synchronized” (consumer_) {  
90	      consumer_.notify();  
91	     }  
92	    }  
93	   }  
94	  }  
95	  
96	  public class Consumer {  
97	   consume() {  
98	    while (!done) {  
99	     “synchronized” (queueLock_) {  
100	      “synchronized” (consumer_) {  
101	       consumer_.wait();  
102	      }  
103	      removeItemFromQueueAndProcessIt();  
104	     }  
105	    }  
106	   }  
107	  }  
108	 }  
109	}  


在代码二中,Producer向队列加入一项新的内容后通知Consumer,以便它处理新的内容。问题在于,Consumer可能保持加在队列上的锁,阻止Producer访问队列,甚至在Consumer等待Producer的通知时也会继续保持锁。这样,由于Producer不能向队列添加新的内容,而Consumer却在等待Producer加入新内容的通知,结果就导致了死锁。

  在等待时占有的锁是一种隐性的死锁,这是因为事情可能按照比较理想的情况发展Producer线程不需要被Consumer占据的锁。尽管如此,除非有绝对可靠的理由肯定Producer线程永远不需要该锁,否则这种编程方式仍是不安全的。有时占有并等待还可能引发一连串的线程等待,例如,线程A占有线程B需要的锁并等待,而线程B又占有线程C需要的锁并等待等。

  要改正代码二的错误,只需修改Consumer类,把wait()移出synchronized()即可。

因此避免死锁的一个通用的经验法则是:当几个线程都要访问共享资源ABC时,保证使每个线程都按照同样的顺序去访问它们,比如都先访问A,在访问BC
 
此外,Thread类的suspend()方法也很容易导致死锁,因此这个方法已经被废弃了.


数据竞争                                                                                                                                                                                                                          

  数据竞争是由于访问共享资源(例如变量)时缺乏或不适当地运用同步机制引起。如果没有正确地限定某一时刻某一个线程可以访问变量,就会出现数据竞争,此时赢得竞争的线程获得访问许可,但会导致不可预知的结果。

  由于线程的运行可以在任何时候被中断(即运行机会被其它线程抢占),所以不能假定先开始运行的线程总是比后开始运行的线程先访问到两者共享的数据。另外,在不同的VM上,线程的调度方式也可能不同,从而使数据竞争问题更加复杂。

  有时,数据竞争不会影响程序的最终运行结果,但在另一些时候,有可能导致不可预料的结果。

  良性数据竞争

  并非所有的数据竞争都是错误。考虑代码三的例子。假设getHouse()向所有的线程返回同一House,可以看出,这里会出现竞争:BrickLayerHouse.foundationReady_读取,而 FoundationPourer写入到House.foundationReady_

public class House {
 public volatile boolean foundationReady_ = false;
}

public class FoundationPourer extends Thread {
 public void run() {
  House a = getHouse();
  a.foundationReady_ = true;
 }
}

public class BrickLayer extends Thread {
 public void run() {
  House a = getHouse();
   while (!a.foundationReady_) {
    try {
     Thread.sleep(500);
    }
    catch (Exception e) {
     System.err.println(“Exception:” + e);
    }
   }
  }
 } 
}


尽管存在竞争,但根据Java VM规范,Boolean数据的读取和写入都是原则性的,也就是说,VM不能中断线程的读取或写入操作。一旦数据改动成功,不存在将它改回原来数据的必要(不需要回退),所以代码三的数据竞争是良性竞争,代码是安全的。

 恶性数据竞争                                                                                                                                                                                                              

  首先看一下代码四的例子。

public class Account {
 private int balance_; // 账户余额
 public int getBalance(void) {
  return balance_;
 }
 public void setBalance(int setting) {
  balance_ = setting;
 }
}

public class CustomerInfo {
 private int numAccounts_;
 private Account[] accounts_;
 public void withdraw(int accountNumber, int amount) {
  int temp = accounts_[accountNumber].getBalance();
  temp = temp - amount;
  accounts_[accountNumber].setBalance(temp);
 }
 public void deposit(int accountNumber, int amount) {
  int temp = accounts_[accountNumber].getBalance();
  temp = temp + amount;
  accounts_[accountNumber].setBalance(temp);
 }
}


如果丈夫A和妻子B试图通过不同的银行柜员机同时向同一账户存钱,会发生什么事情?让我们假设账户的初始余额是100元,看看程序的一种可能的执行经过。

B存钱25元,她的柜员机开始执行deposit()。首先取得当前余额100,把这个余额保存在本地的临时变量,然后把临时变量加25,临时变量的值变成125。现在,在调用setBalance()之前,线程调度器中断了该线程。

A存入50元。当B的线程仍处于挂起状态时,A这面开始执行deposit ()getBalance()返回100(因为这时B的线程尚未把修改后的余额写入),A的线程在现有余额的基础上加50得到150,并把150这个值保存到临时变量。接着,A的线程在调用setBalance()之前,也被中断执行。

  现在,B的线程接着运行,把保存在临时变量中的值(125)写入到余额,柜员机告诉B说交易完成,账户余额是125元。接下来,A的线程继续运行,把临时变量的值(150)写入到余额,柜员机告诉A说交易完成,账户余额是150元。

  最后得到的结果是什么?B的存款消失不见,就像B根本没有存过钱一样。

  也许有人会认为,可以把getBalance()setBalance()改成同步方法保护Account.balance_,解决数据竞争问题。其实这种办法是行不通的。synchronized关键词可以确保同一时刻只有一个线程执行getBalance()setBalance()方法,但这不能在一个线程操作期间阻止另一个线程修改账户余额。

要正确运用synchronized关键词,就必须认识到这里要保护的是整个交易过程不被另一个线程干扰,而不仅仅是对数据访问的某一个步骤进行保护。

  所以,本例的关键是当一个线程获得当前余额之后,要保证其它的线程不能修改余额,直到第一个线程的余额处理工作全部完成。正确的修改方法是把deposit()withdraw()改成同步方法。

  死锁、隐性死锁和数据竞争是Java多线程编程中最常见的错误。要写出健壮的多线程代码,正确理解和运用synchronized关键词是很重要的。另外,好的线程分析工具,例如JProbe Threadalyzer,能够极大地简化错误检测。对于分析那些不一定每次执行时都会出现的错误,分析工具尤其有用。




目录
相关文章
|
25天前
|
存储 监控 Java
【Java并发】【线程池】带你从0-1入门线程池
欢迎来到我的技术博客!我是一名热爱编程的开发者,梦想是编写高端CRUD应用。2025年我正在沉淀中,博客更新速度加快,期待与你一起成长。 线程池是一种复用线程资源的机制,通过预先创建一定数量的线程并管理其生命周期,避免频繁创建/销毁线程带来的性能开销。它解决了线程创建成本高、资源耗尽风险、响应速度慢和任务执行缺乏管理等问题。
157 60
【Java并发】【线程池】带你从0-1入门线程池
|
14天前
|
存储 网络协议 安全
Java网络编程,多线程,IO流综合小项目一一ChatBoxes
**项目介绍**:本项目实现了一个基于TCP协议的C/S架构控制台聊天室,支持局域网内多客户端同时聊天。用户需注册并登录,用户名唯一,密码格式为字母开头加纯数字。登录后可实时聊天,服务端负责验证用户信息并转发消息。 **项目亮点**: - **C/S架构**:客户端与服务端通过TCP连接通信。 - **多线程**:采用多线程处理多个客户端的并发请求,确保实时交互。 - **IO流**:使用BufferedReader和BufferedWriter进行数据传输,确保高效稳定的通信。 - **线程安全**:通过同步代码块和锁机制保证共享数据的安全性。
65 23
|
8天前
|
前端开发 Cloud Native Java
Java||Springboot读取本地目录的文件和文件结构,读取服务器文档目录数据供前端渲染的API实现
博客不应该只有代码和解决方案,重点应该在于给出解决方案的同时分享思维模式,只有思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
Java||Springboot读取本地目录的文件和文件结构,读取服务器文档目录数据供前端渲染的API实现
|
1月前
|
安全 Java 开发者
Java并发迷宫:同步的魔法与死锁的诅咒
在Java并发编程中,合理使用同步机制可以确保线程安全,避免数据不一致的问题。然而,必须警惕死锁的出现,采取适当的预防措施。通过理解同步的原理和死锁的成因,并应用有效的设计和编码实践,可以构建出高效、健壮的多线程应用程序。
45 21
|
21天前
|
Java 调度
【源码】【Java并发】【线程池】邀请您从0-1阅读ThreadPoolExecutor源码
当我们创建一个`ThreadPoolExecutor`的时候,你是否会好奇🤔,它到底发生了什么?比如:我传的拒绝策略、线程工厂是啥时候被使用的? 核心线程数是个啥?最大线程数和它又有什么关系?线程池,它是怎么调度,我们传入的线程?...不要着急,小手手点上关注、点赞、收藏。主播马上从源码的角度带你们探索神秘线程池的世界...
91 0
【源码】【Java并发】【线程池】邀请您从0-1阅读ThreadPoolExecutor源码
|
1月前
|
数据采集 JSON Java
Java爬虫获取微店快递费用item_fee API接口数据实现
本文介绍如何使用Java开发爬虫程序,通过微店API接口获取商品快递费用(item_fee)数据。主要内容包括:微店API接口的使用方法、Java爬虫技术背景、需求分析和技术选型。具体实现步骤为:发送HTTP请求获取数据、解析JSON格式的响应并提取快递费用信息,最后将结果存储到本地文件中。文中还提供了完整的代码示例,并提醒开发者注意授权令牌、接口频率限制及数据合法性等问题。
|
1月前
|
Java 程序员 开发者
Java社招面试题:一个线程运行时发生异常会怎样?
大家好,我是小米。今天分享一个经典的 Java 面试题:线程运行时发生异常,程序会怎样处理?此问题考察 Java 线程和异常处理机制的理解。线程发生异常,默认会导致线程终止,但可以通过 try-catch 捕获并处理,避免影响其他线程。未捕获的异常可通过 Thread.UncaughtExceptionHandler 处理。线程池中的异常会被自动处理,不影响任务执行。希望这篇文章能帮助你深入理解 Java 线程异常处理机制,为面试做好准备。如果你觉得有帮助,欢迎收藏、转发!
129 14
|
1月前
|
Java API 数据处理
深潜数据海洋:Java文件读写全面解析与实战指南
通过本文的详细解析与实战示例,您可以系统地掌握Java中各种文件读写操作,从基本的读写到高效的NIO操作,再到文件复制、移动和删除。希望这些内容能够帮助您在实际项目中处理文件数据,提高开发效率和代码质量。
31 4
|
1月前
|
安全 Java 程序员
Java 面试必问!线程构造方法和静态块的执行线程到底是谁?
大家好,我是小米。今天聊聊Java多线程面试题:线程类的构造方法和静态块是由哪个线程调用的?构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节有助于掌握Java多线程机制。下期再见! 简介: 本文通过一个常见的Java多线程面试题,详细讲解了线程类的构造方法和静态块是由哪个线程调用的。构造方法由创建线程实例的主线程调用,静态块在类加载时由主线程调用。理解这些细节对掌握Java多线程编程至关重要。
60 13
|
1月前
|
安全 Java 开发者
【JAVA】封装多线程原理
Java 中的多线程封装旨在简化使用、提高安全性和增强可维护性。通过抽象和隐藏底层细节,提供简洁接口。常见封装方式包括基于 Runnable 和 Callable 接口的任务封装,以及线程池的封装。Runnable 适用于无返回值任务,Callable 支持有返回值任务。线程池(如 ExecutorService)则用于管理和复用线程,减少性能开销。示例代码展示了如何实现这些封装,使多线程编程更加高效和安全。