Java高级编程基础:多线程应用的高效安全,不可变对象设计很重要

2019-08-19   道以致远

多线程设计之不可变性

实现多线程应用程序有时比乍一看要复杂得多。因此,在开始项目时,我们必须在头脑中先有一个清晰的设计。

因为多线程应用程序,不可避免的存在共享资源并发访问问题,所以在并发多线程应用设计时,我们一般情况下要尽量的少提供对共享资源的直接状态改变操作。

因此,我们在多线程类和方法设计时,一个非常重要的原则是尽量保持对象状态的不可变性,从而降低多线程应用程序设计的复杂性。

不可变的对象

顾名思义,对象本身的状态在运行过程中不会被外部轻易改变。从而保证多线程访问的一致性。

关于对象的是不可变性设计,它在这多线程并发应用设计中被认为是一个非常重要的设计规则。

如果在不同的线程之间共享对象实例,则必须注意两个线程不会同时修改同一个对象。

怎样做到让不可修改对象不被用户修改呢?这种设计很容易实现。

比如,当我们想要修改数据时,要求总是必须构造一个该类的新的实例。我们Java中的基本类java.lang.String就是不可变类的一个例子。

每次我们想改变一个字符串时,都会得到一个新的实例,而非在原来的字符串上做的修改:

String str = "abc";
String substr = str.substring(1);

虽然对象创建是一个不可能没有成本的操作,但是这些对象的创建成本常常被我们夸大了。

我们在设计多线程应用时,如果使用不可变对象的简单设计比不使用具有并发性错误风险的不可变对象更重要,那么我们就必须权衡一下利弊了。

下面是我们在设计不可变对象时常用的一些规则,如果想让一个类不可变的时候,这些规则可以应用:

  • 所有字段都应该是final和private。
  • 不应该有setter方法。
  • 类本身应该声明为final,以防止子类违反不变性原则。
  • 如果字段不是原始类型,而是对另一个对象的引用:

-不应该有直接向调用者公开引用的getter方法。

-不要更改引用的对象(或至少更改这些引用对对象的客户机不可见)。

以下类的实例表示包含主题、消息体和几个键/值对的消息:

这里定义的类是不可变的,因为它的所有字段都是final和private。

它没有定义公开的能够在实例构造之后用来修改实例的状态的方法。

因为String本身是一个不可变的类,所以该类返回对subject和message的引用都是安全的。

因此,获得消息引用的调用者就不能直接修改它。

使用header的Map时,我们必须多加注意。如果我们仅直接返回对Map的引用那么就允许调用者更改其内容。

因此,我们必须返回通过Collection.unmodifiableMap()调用获得的不可修改Map。

该方法将返回Map的一个视图,该视图允许调用者读取值(同样是字符串),但不允许修改。

当试图修改Map实例时,将会抛出一个UnsupportedOperationException异常。

在本例中,返回Map的特定键的值也是安全的,比如getHeader(String key)中,因为它返回的字符串String是不可变的。

但如果Map包含的对象本身不是不可变的,那么这个操作就不是线程安全的了。

API设计时不可变对象

另外我们在设计对外应用程序接口时,也会大量使用不可变对象状态。特别是在设计类的公共方法(即该类的API)时,我们也可以通过不可用对象设计来让它适合多线程调用。

比如我们可以设置当对象处于某种状态时,可能有一些方法不应该执行,从而避免在某个状态下,对象被修改。

处理这种情况的一个简单的解决方案是使用一个私有标志变量,它指示我们处于什么状态,并在不应该调用特定方法时抛出一个IllegalStateException:

上面代码示例中使用的模式也经常被称为“阻塞模式”,因为一旦方法在错误的状态下执行,它就会阻塞。

我们还可以使用静态工厂方法来设计相同的功能,而不用检查每个方法对象的状态:

这里我们的静态工厂方法使用私有构造函数创建FactoryTask的一个新实例,并已经对该实例调用start()。

FactoryTask返回的引用已经处于要处理的正确状态,因此只需要同步getResults()方法,但不需要检查对象的状态。

线程本地存储

到目前为止,我们前面讲的都是关于线程共享相同的内存。就性能而言,这是在线程之间共享数据的好方法。

如果我们使用单独的进程来并行执行代码,我们就会有更多繁重的数据交换方法,比如我们常见的远程过程调用或文件系统或网络级别上的同步操作。

但是,如果没有正确地同步,在不同线程之间共享内存也很难处理。

通过Java为我们提供的java.lang.ThreadLocal类,我们可以定义和设置让单个线程使用而不供其他线程使用的专用内存空间。

private static final ThreadLocal myThreadLocalInteger = new ThreadLocal();

应该存储在ThreadLocal中的数据类型由泛型模板参数T给出。

在上面的例子中,我们只使用Integer,但是我们也可以使用其他任何数据类型。

执行结果如下:

这里可能有一个小小的疑惑需要说明,虽然变量threadLocal被声明为静态的,但是每个线程都输出它通过构造函数得到的值。

ThreadLocal的内部实现确保每次调用set()时,给定的值都存储在只有当前线程才能访问的内存区域中。

因此,尽管在此期间其他线程可能已经调用了set(),但当我们在调用get()时,还能检索之前设置的值。

这个线程本地变量类很重要,比如我们的Java EE世界中的应用服务器就大量使用ThreadLocal特性,因为我们有许多并行线程,但是每个线程都有自己的事务或安全上下文。

通过它我们可以不必在每个方法调用中传递这些对象,只需将其存储在线程自己的内存中,然后在需要时访问它即可。

总结

本文简单介绍了我们在多线程设计时通常采用的一个重要原则,就是尽量使用不可变对象,因为不变代表着更低的风险。

特别要注意String字符串特性在设计不可变对象中的使用,还有就是在返回Map时,最好使用其提供的那些返回Map视图的方法,从而保护Map内容不被修改。

在设计对外的公共方法API时,我们需要注意使用私有化构造方法,通过提供静态工厂方法调用私有构造器等方式来消除对内部状态的直接修改。

从而更好的支持多线程的调用安全性。当然这些对象设计都是在多线程应用中属于共享资源的范畴,也就是说它们操作的是线程的共享内存空间。

为了更好的安全性,我们还介绍了映射到单个线程的内存空间的共享ThreadLocal类的使用。虽然它是一个静态的变量,但是可实际的存储内存空间则是位于单个线程内部,所以具备很好的安全性,不会被其它线程共享。