深圳幻海软件技术有限公司 欢迎您!

Java传统编程模型存在的问题

2023-02-27

 Actor模型不仅仅被认为是一种高效的解决方案,它已经在世界上一些要求最苛刻的应用中得到了验证,为了突出Actor模型所解决的问题,本节首先讨论传统编程模型与现代多线程和多CPU的硬件架构之间的不匹配:对面向对象中封装(encapsulation)特性的挑战对共享内存在现代计算机架构上的

 Actor模型不仅仅被认为是一种高效的解决方案 ,它已经在世界上一些要求最苛刻的应用中得到了验证,为了突出Actor模型所解决的问题,本节首先讨论传统编程模型与现代多线程和多CPU的硬件架构之间的不匹配:

  • 对面向对象中封装(encapsulation)特性的挑战
  • 对共享内存在现代计算机架构上的误解
  • 对调用堆栈的误解

对封装特性的挑战

封装(encapsulation)是面向对象编程(OOP)中的一大特性,封装意味着对象内部的数据不能够在对象外直接访问,必须通过对象提供的一系列方法来间接进行访问。对象负责公开对数据的安全操作的方法,以保护其封装的数据的不变性。

在多线程下,多个线程同时调用同一个对象的方法来修改其内部封装的数据时候,就会存在线程安全问题,这是因为封装本身不确保对象内部数据的一致性,在不对对象的方法在修改数据施加一定同步措施时,对象内的数据就会在多线程访问下变得不确定了。

一个解决该问题的方式就是,多线程访问对象方法内数据时候施加一定同步措施,比如加锁,加锁可以保证同时只有一个线程可以访问对象内的数据,但是加锁会带来昂贵的代价:

  • 使用锁会严重影响并发度,使用锁在现在CPU架构中是一个比较昂贵的操作,因为当线程获取锁失败后会把线程从用户态切换到内核态把线程挂起,稍后唤醒后又需要从内核态切换到用户态进行运行。
  • 获取锁失败的调用线程会被阻塞挂起,因此它不能做任何有意义的事情。即使在桌面应用程序中这也是不可取的,我们想要的是即使后台有一个运行比较耗时的工作在运行,也要保证系统对用户的一部分请求有响应。在后端应用中,阻塞是完全浪费资源的。另外可能有人认为虽然当前线程阻塞了,但是我们可以通过启动新线程来弥补这一点,但是需要注意一点,线程也是一种昂贵的资源,操作系统对线程个数是有限制的。
  • 另外锁的存在,带来了新的威胁,即死锁问题的存在。

由于以上问题的存在,导致我们进退两难:

  • 如果不使用足够的锁,则不能保证多线程下对象中数据不受到破坏。
  • 如果在对象中每个数据访问是都加了锁,则会导致系统性能下降,并且很容易导致死锁的发生。

另外,锁只能在单JVM内(本地锁)很好的工作。当涉及到跨多台机协调时,只能使用分布式锁。但是分布式锁的效率比本地锁低几个数量级,这是因为分布式锁协议需要跨多台机在网络上进行多次往返通信,所以其造成较大的影响就是延迟。

小结:

  • 对象只能在单线程情况下保证封装的安全性,也就是保证对象封装的数据的线程安全性。多线程下修改对象内的数据大多情况下会导致数据被污染,造成脏数据产生。在同一代码段中有两个竞争线程会导致违反每个不变式。
  • 虽然锁看起来是保证多线程下封特性比较直接的方式,但实际上使用锁的效率低下,并且在任何实际规模的应用中都容易导致死锁的产生。
  • 本地锁是我们经常使用的,但是如果尝试将其扩展为分布式锁,则是有代价的,并且其横向扩展的潜力有限。

对共享内存在现代计算机架构上的误解

在80-90年代的编程模型概念化地表示,写入变量时候是直接把其值写入主内存里面(这有点混淆了局部变量可能只存在于cpu寄存器中的事实)。在现在计算机硬件架构中,计算机系统中为了解决主内存与CPU运行速度的差距,在CPU与主内存之间添加了一级或者多级高速缓冲存储器(Cache),每个cache有好多cache行组成,这些Cache一般是集成到CPU内部的,所以也叫 CPU Cache。所以当我们写入变量的时候实际是写入到了当前cpu的Cache中,而不是直接写入到主内存中,并且当前cpu核对自己cache写入的变量对其他cpu核是不可见的,这即是Java内存模型中共享变量的内存不可见问题。

在JVM中我们可以把变量使用volatile关键字修饰或者使用JUC包中的原子性变量比如AtomicLong对普通变量进行包装来保证多线程下共享变量的内存可见性,当然使用加锁的方式也可以保证内存可见性,但是其开销更大。既然使用volatile关键字可以解决共享变量内存可见性问题,那么为何不把所有变量都使用volatile修饰那?这是因为使用volatile修饰变量,写入该变量的时候会把cache直接刷新会内存,读取时候会把cache内缓存失效,然后从主内存加载数据,这就破坏了cache的命中率,对性能是有损的。

所以我们需要细心的分析哪些变量需要使用volatile修饰,但是即使开发人员意识到volatile可以解决内存不可见问题,但是从系统中找出哪些变量需要使用volatile或者原子性结构进行修饰也是一个困难的事情,这使得我们在非业务逻辑处理上需要耗掉一部分精力。

小结:

  • 在现在多核CPU的硬件架构中,多线程之间不再有真正的共享内存,cpu核心之间显示传递数据块(cache 行)将和网络中不同计算机之间传递数据一样。 CPU核心之间通信和网络通信的共同点比许多人意识到的要多。现在跨CPU或联网计算机传递消息已成为一种规范。
  • 除了通过使用volatile修饰共享的变量或使用原子数据结构保证共享变量内存可见性之外,一个更严格和原则上的方法是将状态保持在并发实体本地,并通过消息显式在并发实体之间传播数据或事件。

对调用堆栈的误解

提起调用堆栈( Call stacks)大家都耳熟能详,但是其被发明是在并发编程不是那么重要时候,那时候多核cpu系统还不常见,所以调用堆栈不会跨线程,因此不会为异步调用链提供调用堆栈能力。

在多线程下,当主线程(调用线程)开启一个异步线程运行异步任务时候,问题就出现了。这时候主线程会将共享对象放到异步线程可以访问到的共享内存里面,然后开启异步线程后主线程继续做自己的事情,而异步线程则会从共享内存中访问到主线程创建的共享对象,然后进行异步处理。

进行异步处理时候遇到的第一个问题是当异步线程执行完毕任务后,如何通知主线程?另外当异步任务执行出现异常时候该怎么做?这个异常将会被异步线程捕获,并且不会传递给主调用线程。

理论上主调用线程需要在异步任务执行完毕或者出异常时候被通知,但是没有调用堆栈可以传递异常。异步任务执行失败的的通知只能通过辅助方式完成,比如Future方式,将错误码写到主调用线程所在的地方,否则一旦准备好就希望得到结果。如果没有此通知,则主调用线程将永远不会收到失败通知,并且任务将丢失!这类似于网络系统的工作方式,其中消息/请求可能会丢失/失败而不会发出任何通知。

当真的发生错误时,这种情况会变得更糟,当异步工作线程遇到错误时候会导致最终陷入无法恢复的境地。例如由错误引起的内部异常会冒泡到线程的根,并使线程关闭。这立即引发了一个问题,谁应该重新启动该异步线程执行的任务,以及如何将其还原到已知状态?乍一看,这似乎是可以管理的,但我们突然遇到了一个新的现象:异步线程当前正在执行的实际任务我们并没有存放起来。实际上,由于到达顶部的异常使所有调用栈退出,任务状态已经完全丢失了!即使这是本地通信,也没有网络连接,但是我们还是丢失了一条消息(可能会丢失消息)。

小结:

为了在当前系统上实现任何有意义的并发性和提高性能,线程必须以高效的方式在彼此之间委派任务,而不会阻塞。使用这种类型的任务委派并发(甚至在网络/分布式计算中更是如此),基于调用堆栈的错误处理会导致崩溃。因此需要引入新的显式错误信令机制,让失败成为域模型的一部分。

具有工作委派的并发系统需要处理服务故障,并需要具有从故障中恢复的原则方法。此类服务的客户端需要注意,任务/消息可能会在重新启动期间丢失。即使没有发生损失,由于先前排队的任务(较长的队列)或者垃圾回收导致的延迟等,将会导致响应可能会被任意延迟。面对这些情况,并发系统应以超时的形式处理响应截止日期。