并发编程需要注意的问题

并发编程需要注意的问题

一个并发程序的执行遇到的问题可谓是千奇百怪,我们从微观的角度能把并发问题分为

  1. CPU缓存导致的可见性问题。
  2. 编译优化带来的排序问题。
  3. 线程切换带来的原子性问题。

但学习讲究微观和宏观相结合,所以这里聊聊宏观角度上的并发编程问题。

安全性问题

什么称作线程安全呢?

线程安全即是指程序能按照我们预期的结果执行,那是不是所有的线程都要检查并发程序是否存在安全性问题呢?

当然不是,只有当多个线程同时读写同一个共享变量,这时候就需要考虑安全性问题。

既然安全性问题的本质就是因为读取了共享变量,那是不是不共享变量或者共享变量不发生变化就可以避免呢?当然是可以有很多技术是依赖这个理论如线程本地存储(Thread Local Storage,TLS),不变模式等等。

但现实世界应用场景较多的还是需要改变共享变量的值,代码场景如下

public class Test {
  private long count = 0;
  void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
}

当多个线程读取共享变量,同时至少存在一个变量写共享变量,如果不采取措施那么就会线程不安全,这里就称之为数据竞争如上诉代码中的add10k多个线程调用就会导致安全性问题。

那改造成如下代码是不是就解决线程安全性问题呢?将get方法和set方法都加锁

class Test1{
    private long count = 0;
    
    private synchronized long get(){
        return count;
    }
    
    private synchronized void set(long count){
        this.count = count;
    }
    
    public void add10k(){
        int idx = 0;
        while (idx<10000){
            set(get()+1);
        }
    }
}

并没有彻底解决,如目前有线程T1,T2同时访问add10k方法,多个线程在set前获取get后,最后set的就会将前面线程设置的值覆盖掉,最后的count结果却变为了1,而不是预期中的2,因为add10k()方法中的set(get()+1)并不是互斥,所以count的值和两个线程执行的先后顺序有关,同时执行那么count结果就是1,前后执行的结果就是2,这种情况称之为竞态条件

所谓竞态条件,即执行的结果和线程执行的先后顺序有依赖关系。

上诉例子中的竞态条件是隐藏的,set(get()+1)这里的set方法结果就是依赖get方法的返回, 这样的例子竞态条件并不明显,所以请参考如下转账例子

class Account{
	// 账户余额
	private long balance;

	/**
	 * 转账
	 * @param targer 目标账户
	 * @param amt  转账金额
	 */
	public void transfer(Account targer,long amt){
		if (this.balance>=amt){
			this.balance-=amt;
			targer.balance+=amt;
		}
	}
}

转账逻辑检查账户余额是否大于转账金额,如果并发条件下,账户A有200元钱,线程T1从账户A向账户B转账150元,线程T2从账户A向账户C转账100元,假设存在线程T1,T2同时到达第11行代码检测是否余额大于转账数,发现都满足执行扣除金额逻辑,这时就会出现超额转出问题。

所以竞态条件也可以这样理解,并发条件下,某个代码块的执行依赖某个变量的状态,抽象为如下逻辑

if(状态变量 满足 执行条件){
    执行操作
}

综上竞态条件重新定义如下

当并发场景下,多个线程通过读取状态变量值满足了执行条件正准备执行操作代码,这时一个线程将状态变量改变,导致状态变量不再满足执行条件,从而引发并发安全问题,这就是竞态条件。

那么这类数据竞争和竞态条件如何解决呢?

其实只要保证互斥条件用锁即可,在线程读取状态变量执行操作时,其它线程不准进入就能避免此类问题。

活跃性问题

什么是活跃性问题

指线程因为某些原因无法执行下去,这就是活跃性问题,常见的有死锁、活锁、线程饥饿。死锁比较常见所以先聊聊活锁和线程饥饿。

活锁

定义

活锁并不像死锁一样线程卡死无限等待,而是无限执行,以生活中的例子说明

假如有用户A,B两人,用户A从门左边出门,用户B从右边进门,这时发现会有相撞的风险而互相谦让,用户A从右边出门,用户B从左边进门这时还是发现会有相撞的风险又再次谦让,在现实世界来讲相互谦让几次就可以正常通行,但是电脑是一台二进制指令的机器,暂时无法做到如此智能,那只会无休止的谦让下去,这就是活锁。

解决

解决活锁的方案可以参考现实世界,在用户A发现左边有人有相撞的风险,并不是马上切换到右边,而是随机等待一段时间再去切换到右边,同样用户B也不是马上切换位置,而是随机等待时间再去切换,这样做第二次发生碰撞的几率就大大降低了。

线程饥饿

定义

线程因为无法获取资源,而长久等待阻塞的情况。

例如线程的优先级不够,而且CPU繁忙导致资源分配不均,线程饥饿。又如线程长时间持有锁导致其它线程无法获取锁也是导致线程饥饿的原因。

解决

想要解决线程饥饿,那么可以从以下三个方面考虑:

  1. 保证资源充足。
  2. 保证线程的平等性。
  3. 避免锁的长时间持有。

从开发角度出发1、3点不太可控,但是第2点可以勉强保证,在并发JUC包中有定义一种公平锁,代码如下

Lock lock = new ReentrantLock(true);
// 构造方法
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

公平锁采用先来后到的思想,线程等待有先后顺序,排在等待队列前的线程优先获取资源。

性能问题

并发程序的效率高低,和锁的正确使用密不可分,锁的使用应该尽可能的细粒度化,影响范围小才能更好的发挥出并发程序的优势,如转账案例

public class Account {
    private Integer balance;
    
    public void transfer( Account target, int amt){
        // 获取锁 使用类模板
        synchronized (Account.class){
            if (this.balance > amt) {
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

每次用户A向用户B转账都需要锁住整个Account.class的话,虽然安全性保证了,但是效率却是大幅度下降了,程序的串行化过高。

假如上诉代码串行化为5%,那么多线程下对程序的性能提升比单线程快多少呢?这里引用阿姆达尔定律,能够计算处理器并行后效率提升的能力,如下

S = 1 ( 1 − p ) + p n S=\frac{1}{(1−p)+\frac{p}{n}} S=(1p)+np1
其中n表示CPU的核数,p表示并行百分比那么1-p理解为串行百分比。

假如CPU核数为无限大,那么p/n也就趋近于0,1-p为串行比也就是5%,那么S最大也只能为20,话句话说如果程序的串行比高,那么无论CPU核数多大,多线程的并发是上不去的,所以我们应该合理使用锁。

如何提升性能

采用无锁方案

既然锁对性能存在一定的影响,那么采用无锁的算法和数据结构即可,这方面的方案很多如,本地线程存储(TLS)、COW(写入时复制)、乐观锁等

减少锁持有时间

互斥锁的本质就是将并行的程序串行化,所以想要提升并行度,必须减少锁持有的时间,那么可以采用细粒度化锁典型代表就是JDK1.7的ConcurrentHashMap底层采用的就是分段式锁,增加并行度,也可以采用读写锁,读读不互斥,读写和写写互斥这样能够将锁细粒度化。

posted on   Java面试365  阅读(35)  评论(0编辑  收藏  举报

相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异
· 三行代码完成国际化适配,妙~啊~

导航

< 2025年3月 >
23 24 25 26 27 28 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5
点击右上角即可分享
微信分享提示