不变性immutablity设计

11-10-21 banq
不变性是统领业务分析和高性能架构重要法门,通过业务上不变性分析设计,可以实现代码运行的并发高性能和高扩展性

不可变性是一种抽象,它并不在自然界中存在,世界是可变的,持续不断变化。所以数据结构是可变的,他们是描述真实世界某段时间内的状态。而状态经常会被修改,如下面情况:

1.状态被并发线程同时修改

2.多个用户对一个共享对象(状态)进行冲突性修改。

3.用户提供了无效数据,应该被回滚。

在自然可变的模型下,会在上面情况下发生不一致性或数据破碎crushing,显然可变性很容易导致错误。我们需要的是一种新视野,新角度,一种事务transaction视野,当你从事务性程序中看世界时,一切都是不可变的。这和蒯因与引用透明 中功能重复执行不可变原理非常类似,体现逻辑的线性特征。

不可变性说得这么玄,是不是和平常编程没有太大关系呢?

完全错误。举个例子,当你要对一个变量进行修改时,这个变量处于一个对象A内,你是直接对这个对象(A类的实例)内这个变量进行修改?还是重新创建一个A类新的对象实例,新的变量在新的对象实例中,然后替换掉原来的对象呢?

很多程序员对这么一个权衡问题没有过多考虑,基本是随意选择其中方案,甚至根据工作量多少为抉择,这些不经意行为都为整个系统的性能留下祸根。

这个问题其实涉及到了不可变性的问题,如果A类被设计为不可变了,那么就要采取第二个的方案,创建后替换,这个答案带来两个问题:

1.程序员如何知道A类是一个不可变的类?

2.不可变类为什么能够获得性能提高?

关键问题是第一个,我认为我们可以通过OOA/OOD等建模方式明确哪个类为不可变,很显然DDD领域驱动设计方法中,有值对象为不可变的概念,如果领域专家指定来自需求中哪个类是值对象,那么我们程序员就容易在编程实践中采取构建替换的办法,从而又能照顾到了计算机底层的性能。

我2006年在jivejdon按照DDD设计开发时,探索性地提出状态可以表达设计为值对象概念,见实战DDD(Domain-Driven Design领域驱动设计:Evans DDD),虽然此后一直有反复和怀疑,但是最近看了如何打败CAP定理?一文以后,比较肯定了自己当初想法,心中一块石头落地,这是一种探索的乐趣。

下面谈谈我如何在JiveJdon 4.5版本中如何把值对象状态不可变性贯彻落实完整的情况,在之前版本因为心中不确定,所以对状态修改时,没有遵循上面提到的第二步法则,而是直接对对象字段修改,就好象直接去修改数据表字段那样鲁莽。

这种破坏性修改的恶果也让我吃尽苦头,就是数据出现严重的不一致性,如帖子更新状态,经常是增加新帖后,在论坛列表的“最近更新”栏目出现的不是最后增加的那个新帖,有时正确有时错误,头疼不已,当时是非常怀念基于数据库的编程模式,因为这个功能只要通过一条SQL查询就能完成,轻松而简单,而JiveJdon是基于内存中的对象,这个功能是对对象的状态进行修改完成的,方式不同,思路就不一样了,后来虽然费了九牛二虎之力勉强正确,但是不够优雅和轻量,一直是自己不敢面对的痛。

现在理顺了值对象状态不可变性这样一个思路,我几乎三下五除二把JiveJdon这个功能就重构了,而且一次性调试通过。下面谈谈这个重构过程。先展示一下JiveJdon的设计类图:

将ForumThreadState这个经常变换的状态和实体聚合边界内其他变化是不一致的,惨遭DDD一书中的订单价格变化案例设计为单独的ForumThreadState,下面问题是,ForumThreadState变化是直接修改其中字段,还是重新构建一个ForumThreadState替换原来的,不幸的是我和大多数一样,起初没有太多留意走的是前一种办法,绕了不少弯路。

[该贴被banq于2011-10-21 08:45修改过]

37
banq
2011-10-21 09:07
下面谈谈如何重构,过程比较简单,以Forum的ForumState为案例,类似上面的ForumThreadStat。

将ForumState以Java中最大可能的不可变语法表达,在国外论坛上看到不少Scala程序员欢快地使用Scala的不可变+Actor两个方式编写出简单容易并发程序,我也可以借助JdonFramework的ES模型,通过稍微复杂点的不可变+Domain Events实现类似的并发编程,ForumState的稍微复杂点不可变性的代码如下:

public class ForumState {
        private final AtomicLong threadCount;
      
        private final AtomicLong messageCount;

        private final ForumMessage lastPost;

        private final Forum forum;

        private final SubscribedState subscribedState;

        public ForumState(Forum forum, ForumMessage lastPost, long messageCount, long threadCount) {
                super();
                this.forum = forum;
                this.lastPost = lastPost;
                this.messageCount = new AtomicLong(messageCount);
                this.threadCount = new AtomicLong(threadCount);
                this.subscribedState = new SubscribedState(new ForumSubscribed(forum));
        }
<p>

源码见:

http://code.google.com/p/jivejdon/source/browse/trunk/jivejdon/src/com/jdon/jivejdon/model/ForumState.java

Java中体现不可变性的特点主要是final和构造函数,ForumState的就没有了所有setXXXX方法,不能对ForumState内部单独修改,强迫替换整个对象。

有了不可变性的值对象,根据DDD设计要则,还需要一个工厂来维护这种不变性,也就是ForumState的创新new需要通过工厂专门统一实现,不是任意地随意new创建的,

专门创建一个FormStateFactory负责FormState的生命周期维护,FormStateFactory代码:

public interface ForumThreadStateFactory {

        void init(ForumThread forumThread, ForumMessage lastPost);

        void addNewMessage(ForumThread forumThread, ForumMessage newLastPost);

        void updateMessage(ForumThread forumThread, ForumMessage forumMessage);
}
<p>

http://code.google.com/p/jivejdon/source/browse/trunk/jivejdon/src/com/jdon/jivejdon/model/state/ForumThreadStateFactory.java

那么现在来回答上面我提出不可变性编程中第二个问题,不可变性为什么可以获得性能提升呢?

主要是回避了共享锁synchronize,原来在上面的addNewMessage和updateMessage方法前都要加同步锁synchronized的,现在不需要了,因为新对象替换旧对象本身是一个原子操作。对比图见附件图。

在多线程编程中有一个不可变Immutable模式:

Immutable是当被构造以后就不再改变。

Immutable 的对象总是线程安全。

特征:

1. 构造以后就不会改变;

2. 所有字段是 final;

3. 它是正常构造。

发布一个Immutable对象是线程安全的。


banq
2011-10-21 09:14
下面是一些深入思考,欢迎大家探讨:

需要注意的是:不同的事件引起不同的状态,如读取事件引发读状态的变化,比如帖子的浏览数;而写事件引发写状态的变化;越是频繁发生的事件,其不变性的时间周期Scope就越短,实现的手段就有区别。

帖子浏览数是帖子的一种读取次数状态,频繁发生,如果每次读取事件发生,我们象上面采取值对象不变性原则,每次都构造一个新的值对象,无疑会有大量垃圾临时对象产生,从而频繁触发JVM的垃圾回收机制,那么在这种情况下,我们可以采取其他并发措施,比如使用JDK的原子类型AtomicLong等,通过其提供的自增获取的原子功能实现并发。如jivejdon中的ViewCounter模型

还有一种状态是结构关系状态,比如帖子之间的相互回复关系可以组成一个二叉树的模型,如果有新帖写事件发生,会增加一个新帖ID在这个树结构中。这是一种难以避免的在原来数据上进行修改情况。如jivejdon中的ForumThreadTreeModel.java

值得注意的是,上面这些对值对象内部字段的修改一定是要通过聚合根实体的方法进行,不能把值对象暴露给外界直接供外界修改,这样也破坏了聚合边界内的不变性规则约束。比如ForumThread一定要方法对其值对象ViewCounte的修改方法,不能直接将ViewCounte通过getViewCounte()暴露给外部。

由此可见:不变性immutablity设计是贯彻建模设计代码和性能整个环节的一条主要编程线索。这条主要线索又有多少当前程序员能够把握且每天实践的呢?

[该贴被banq于2011-10-21 09:32修改过]

xmuzyu
2011-10-21 11:14
世界上唯一不变的真理就是要变,对于一个事物而言,变化可以通过时间段来表示出来,但是永恒却需要用时间点来概述。比如一个人不同时期可能行为心智都不一样,但是在某个时间点上的行为和心智已经永恒,你不可能回到和一分钟前完全一样的你自己,每个时间点都代表着一种永恒。那么世界上的事物的状态又是如何随着时间来变化的呢?我能想到也就两种,一种是增量型的变化,比如我们每个人的知识的储备就是一个种增量式的变化,这其实可以理解为量变,另外一种就是全新的本质性的变化,比如金庸小说中心中无剑的状态,心中无剑的状态已经不同于以往的状态是一种全新的质的飞跃,这我们可以理解为质变。

那么我们回到软件设计当中的时候,对象状态变化是相对于时间段而言的,但是某一个时间点的状态就已经永恒的存在了,这个时间点的状态其实就是不变性。那么当对象状态在随着时间变化的过程中,我们软件设计人员如何去让对象的状态适应变化呢?我能想到的也就两种,一种就是jivejdon之前采用的synchronized的方式,另外一种就是本帖banq老师所说的不变性状态替换。

下面我再唠叨一下采用不变性的状态的一些想法,对于一个单机的系统,我们是可以通过synchronzied的方式来实现对对象状态的并发修改,而对于一个分布式的系统或者是集群中的时候,就很难使用synchronized了,但是这种情况下,我们转而采用状态替换的方式,就既可以避免分布式加锁带来的复杂性,同时还可以实现对象状态的更新,何乐不为之呢?

jdon007
2011-10-21 11:58
一、唯一不变的是变化本身

早上,我去市场买的蔬菜是新鲜的;晚上,蔬菜却可能不再新鲜。从逻辑上而言,一个命题的真假也可能随着时间的变化而变化。

可以设计具有不变性的状态特征(值对象),但状态特征(值对象)不一定具有不变性,设计不变性的状态特征更多是为了高并发访问(主要是读)的一致性与有效性。

不变性设计的适用的场合—实例的状态被多个线程频繁读取(高并发环境中,实例状态被共享)。

二、Java如何描述可写(可变)与只读(不可变)的状态特征

Java如何描述某个模型(实例)具有不变性的状态特征?

从Java代码外观上看,可描述如下:

1、目标:去掉getter方法的synchronized修饰符

2、手段:

A)去掉setter方法

a) 提供构造器,将需要的字段作为参数(取代setter)

b) 在字段前添加final修饰符(表示不能使用setter)

B) 拆分getter方法

如果字段不具有不变性,也不能直接返回,需要拆分成若干具有不变性的字段(原子)返回。

比如有A类有一个int x, int y私有字段,都具有未经synchronized修饰的公开setter方法;

在B类中将A类作为一个字段,那么就不能直接返回 A getA(); 而应当是 int getX(); int getY()。

如果不这样做,严格意义上讲,A类就不具有不变性。从这点上讲,banq上面所写的ForumState并不具备严格的不变性。

有时候,我们不仅要设计只读的状态特征State,也要设计可写的实例状态StateBuilder。这两种对称的状态特征应当是可以相互转化的,比如ForumStateBuilder 有以ForumState作为参数的构造方法,ForumState有以ForumStateBuilder作为参数的构造方法。

三、OO、FP、LP与Concurrency

在纯函数式(FP)语言中,没有副作用,符号是不可变的。这个特性在OO语言中,可由不变性模式(Immutable pattern)实现。在逻辑式(LP)语言中,被称为“引用透明”。

Immutable Pattern是实现并发的诸多有效手段之一,Actor pattern也一样。但在并发语境中,OO相对于FP、LP这些语言,描述起来可能会比较笨拙一些。

SpeedVan
2011-10-21 14:12
2011年10月21日 11:58 "@jdon007"的内容
可以设计具有不变性的状态特征(值对象),但状态特征(值对象)不一定具有不变性,设计不变性的状态特征更多是为了高并发访问(主要是读)的一致性与有效性。

不变性设计的适用的场合—实例的状态被多个线程频繁读取(高并发环境中,实例状态被共享)。 ...

一、图解

|--------|   
|        |
|     |--|
|     |       变量
|     |--|
|        |
|--------|

   |--------|   
   |     |--|
   |     |
|--|     |--|    变量集合
|--|     |--|
   |     |
   |     |--|
   |--------|

   |-----|   
   |     |
   |     |
|--|     |
|--|     |      值对象
   |     |
   |     |
   |-----|
<p>

缺口是变量(属性),是不确定的。而值对象是确定,因为它是值。

二、观点

jdon007认为,后两者都是值对象,不变设计是因为并发需求。

而我的观点是:不是因为用而不变,而是因为不变而用。(是下面一切的总括)

三、SpeedVan观点详解

前两者其实是同一意思,都是变量。它们都是等待与值对象组合成对象特征(这与jdon007说的特征不一样)。jdon007把1与2、3分开,形成两组概念。而我虽然是三个概念,但前两个相似,可以合并,第二个概念只是为了区别过去扭曲的值对象而提出的。

值对象中,“对象”意味着“边界”,那么“值”意味着什么?我认为是一个系统的原子概念。如同数字系统的1、2、3……、字符系统的'a'、'b'、'c'……、字符串系统的"abc"、"bcd"、"acb"……等。值是已确定的最小单元的个体。所以值对象不存在变化,当结构和边界被确定后,一切就会确定下来。不创建出来,不代表该值概念不存在。

最后,既然谁也说服不了谁,也就不勉强,以后讨论尽可用可变/不变对象,避免歧义。

四、额外语

准确来说面向类只是面向对象一种实现方式,请记住类!=对象。通过类,通过原型对象,通过函数,都可以到达面向对象,只是各自方式不一样而已,有些静态,有些动态,但目的都是构造出边界(边界不是固定的,可静态,可动态,我认为scala这方面真的比较综合)。

2011年10月21日 11:14 "@xmuzyu"的内容
下面我再唠叨一下采用不变性的状态的一些想法,对于一个单机的系统,我们是可以通过synchronzied的方式来实现对对象状态的并发修改,而对于一个分布式的系统或者是集群中的时候,就很难使用synchronized了,但是这种情况下,我们转而 ...

xmuzyu,很久不见了。很少见你的文章了?

回题,在我认识到不变对象后,慢慢地单机也以这样方式来编写。在多核时代,并发已经成为趋势,如何发挥并发实力,也是一个重要课题了。自从banq介绍了Disrupter后,比起synchronzied方式,原子性的并行方式更好。同意banq的一句话:锁是并发计算的大敌。

[该贴被SpeedVan于2011-10-21 14:14修改过]

[该贴被SpeedVan于2011-10-21 14:16修改过]

banq
2011-10-27 09:42
今天看到stackoverflow的关于REST的POST和PUT区别:

http://stackoverflow.com/questions/630453/put-vs-post-in-rest

从其最佳答案中我感觉这个区别有点关系不可变性,POST和PUT都是用于创建,但是创建的方式不同:

“PUT是幂等的(蒯因与引用透明),如果PUT一个对象两次,应该没有什么影响,所以,尽可能使用PUT。”

所以,服务器端对于PUT的处理,不是通常的UPDATE,修改,具体说不是对一个对象的内部值进行修改,而是把这个对象替换,只有用这种不变性处理方式才能实现幂等,无论执行多少次都是一样的。

而POST则可以在同一个URL下同时有两个POST请求,他们分别更新的是一个对象的不同部分。

With POST you can have 2 requests coming in at the same time making modifications to a URL, and they may update different parts of the object.

POST通常用来实现append追加和更新update一个资源。

PUT用来创建一个资源或覆盖原来存在的资源:

创建新资源,只要提供一个新的URL即可:

PUT /questions/<new_question> HTTP/1.1

Host: wahteverblahblah.com

覆盖原来的资源,只要提供已经存在的URL即可:

PUT /questions/<existing_question> HTTP/1.1

Host: wahteverblahblah.com

PUT相当于X=5赋值;而POST相当于X++;

根据这个原则,JiveJdon中ForumState提交要用PUT;而ViewCounter计数器和TreeModel提交要用POST。这下整个任督二脉全部打通。

[该贴被banq于2011-10-27 10:10修改过]

SpeedVan
2011-10-28 00:47
2011年10月27日 09:42 "@banq"的内容
PUT相当于X=5赋值;而POST相当于X++; ...

我个人觉得X=5是一种可并行的值替换,而并行中X++(指的是原子类型)是一种串行操作,即前一个++是必须完成才能后一个++,当然基本类型的串行效率可以认为如同并行一样。

[该贴被SpeedVan于2011-10-28 00:50修改过]

shaou
2012-03-01 09:39
使用Immutable对象确实可以保证一个对象的线程安全性,但是对于这个值对象的holder,也需要保证其线程安全。刚才看了一下代码,想到一个问题,提出来供大家讨论。addNewMessage函数中,需要修改的值有两个,messageCount和newLastPost. messageCount的增量使用了原子类自带的增量函数,所以依然是线程安全的,newLastPost不依赖于旧的lastPost,所以现在的实现没有任何问题。但假如newLastPost对以前的lastPost有依赖性,比如newLastPost =addedFunction(forumThread.getState().getLastPost())(见下面修改后的代码),这种情况就会有读/写冲突问题。这时假设线程T1和线程T2同时调用addNewMessage, T1走到newLastPost =addedFunction(oldState.getLastPost());后被挂起,T2执行newLastPost =addedFunction(oldState.getLastPost());希望的结果是T2获得的newLastPost应该基于T1的newLastPost,但实际结果却是它依然基于oldState.getLastPost()。可见,在这种情况下是需要保证state的holder的线程安全的,也就是说,在state被读取和其被修改之间的操作是需要同步的。那么如何保证其同步性呢?用锁?或者重构代码后用volatile修饰符保证state的安全?。。。修改后的代码如下:

public void addNewMessage(ForumThread forumThread, ForumMessage newLastPost) {

try {

long newMessageCount = forumThread.getState().addMessageCount();

ForumThreadState oldState = forumThread.getState();

newLastPost =addedFunction(oldState.getLastPost());

ForumThreadState forumThreadState = new ForumThreadState(forumThread, newLastPost, newMessageCount);

forumThread.setState(forumThreadState);

} catch (Exception e) {

e.printStackTrace();

}

}

banq
2012-03-01 10:09
2012年03月01日 09:39 "@shaou"的内容
但假如newLastPost对以前的lastPost有依赖性,比如newLastPost =addedFunction(forumThread.getState().getLastPost())(见下面修改后的代码),这种情况就会有读/写冲 ...

思考得很好,如果基于原值又读又写,那么就不得不使用线程安全措施。

反过来思考,我们是试图通过设计对象边界来回避使用线程安全措施,如果一个对象需要基于原来旧对象读了再改,那么按照值对象定义,这个对象大部分就不是一个值对象,是我们建模时就搞错了。

shaou
2012-03-01 14:58
2012年03月01日 10:09 "@banq"的内容
如果一个对象需要基于原来旧对象读了再改,那么按照值对象定义,这个对象大部分就不是一个值对象,是我们建模时就搞错了。 ...

有道理。但有些对象即使不是值对象,为了并发的高效也应该考虑Immutable方式,像这个例子,不使用Immutable就需要在每次读和写的地方都要加锁,而使用了Immutable方式,只在需要读后再写的地方加锁,效率还是有很大提升的。

[该贴被shaou于2012-03-01 14:58修改过]

laseine
2012-03-02 07:31
大家好

看了一下jdon关于论坛状态修改的几个类 关于如下代码有一个疑问

public class Forum extends ForumModel {

private ForumStateFactory forumStateManager;

...

public void addNewMessage(ForumMessageReply forumMessageReply) {

forumStateManager.addNewMessage(this, forumMessageReply);

this.publisherRole.subscriptionNotify(new ForumSubscribed(this));

}

...

}

forumStateManager 是一个 ForumStateFactory 的实例

实现类如下:

public class ForumStateFactoryImp implements ForumStateFactory {

...

public void addNewMessage(Forum forum, ForumMessage newLastPost) {

try {

long newMessageCount = forum.getForumState().addMessageCount();

ForumState forumState = new ForumState(forum, newLastPost, newMessageCount, forum.getForumState().getThreadCount());

forum.setForumState(forumState);

newLastPost.setForum(forum);

} catch (Exception e) {

e.printStackTrace();

}

}

...

}

问题是这个Forum类的addNewMessage 方法并不是线程安全的 如果两个用户同时在同一论坛发表新帖子的话, Forum.addNewMessage(...)将会在多线程中同时调用 同时生成两个ForumState值变量 因为方法ForumStateFactoryImp.addNewMessage(...) 没有同步锁 所以会产生如下错误:

1). 帖子总数可能是错误的 即使有AtomicLong messageCount 保证messageCount是原子操作。因为并发情况下两个线程获得的newMessageCount (newMessageCount = forum.getForumState().addMessageCount(); ) 可能是相同的值 结果两个新的ForumState也包含同样的总帖子数 最后两个新状态相互覆盖的结果是帖子总数被少算了一个

2). 最后发表的帖子也可能是错误的 跟上边的原因是一样的

不知道因为我只看到了局部代码造成的误解 还是因为本身论坛的并不要求高度的一致性 而相比锁带来的性能上的瓶颈而采取的牺牲

请版主或者各位朋友回答一下我的这个疑问 多谢

代码在以下链接可以看到

http://code.google.com/p/jivejdon/source/browse/trunk/jivejdon/src/com/jdon/jivejdon/model/

banq
2012-03-02 10:18
2012年03月02日 07:31 "@laseine"的内容
问题是这个Forum类的addNewMessage 方法并不是线程安全的 ...

我重新看了一下这段代码,虽然addNewMessage其内部操作都是针对的方法参数实现的,但是ForumThread.addNewMessage处需要同步保证线程安全,楼上言之有理。

前面我说经过队列Queue变成串行,那只是针对帖子的创建和删除,addNewMessage是针对修改,目前没有引入Queue事件,也是可以引入事件的,让针对每个帖子的修改事件都变成串行事件,避免资源争夺,这样就无需在addNewMessage处加同步锁了,这是值得改进的地方。

[该贴被banq于2012-03-03 07:58修改过]

laseine
2012-03-05 07:05
谢谢楼主认真负责的回复.

关于把帖子状态设计成值对象, 然后去掉同步锁的问题, 个人理解只要是同一个对象被多线程共同改变, 不管是这个对象是值对象还是聚合根的实体子对象, 都要加锁; 除非是采用单一队列的方式串行处理, 正如Jivejdon所采用的无锁Distruptor队列.

但是这种通过事件或者命令队列串行处理的难点是异步技术处理和同步需求的矛盾, 正如用户发新帖然后返回到刚刚发的新帖页面 如果新帖存储是通过队列异步存储的, 那么就必须实现一个阻塞的读取, 因为显示新帖之前必须等待新帖的保存完成, 那么像这种use case是不是就不应该做异步的处理呢?

banq
2012-03-05 11:42
2012年03月05日 07:05 "@laseine"的内容
个人理解只要是同一个对象被多线程共同改变 ...

如果是修改对象内部值需要加锁,但是更替对象本身无需,相当于this.xxx = xxx, 这时可以用AtomicReference(CAS支持) volatile,适用范围是:写变量值不依赖它当前值。Java theory and practice: Managing volatility

另外如果是值对象,具有不可变性,就可以使用final修饰符号,如果一个对象内部都是final字段,那么这个对象是一个值对象,其改变只要替换即可,依次不断推导到其父对象一层,这里也无需锁,我写本文的意图正是想通过定义其值对象,实现缺省无锁编程,有锁尽量回避的意图。

>那么就必须实现一个阻塞的读取, 因为显示新帖之前必须等待新帖的保存完成, 那么像这种use case是不是就不应该做异步的处理呢?

在Web结构下,浏览器和服务器之间的通讯时间远远大于异步串行造成的延迟时间,也就是说:浏览器把新帖送到服务器,然后从服务器返回到浏览器,再次发出读取查询新帖的命令,当这个新帖命令再次到达服务器时,在这一过程中服务器已经完成新帖保存。(实际如果不放心,可以设置读取新帖时等待1秒)。

Disruptor的异步串行一般只做到1微妙的延迟,所以,可以等同于同步,特别是在分布式系统,客户端和服务器需要TCP通讯情况下,只要其异步串行延迟快于TCP通讯,就相等于同步,但是这种异步方式却提高了整个系统的并发效率。

[该贴被banq于2012-03-05 11:46修改过]

[该贴被banq于2012-03-05 11:47修改过]

[该贴被banq于2012-03-05 12:02修改过]

猜你喜欢
2Go 1 2 下一页