learn-tech/专栏/Java并发编程实战/28Immutability模式:如何利用不变性解决并发问题?.md
2024-10-16 06:37:41 +08:00

220 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
28 Immutability模式如何利用不变性解决并发问题
我们曾经说过,“多个线程同时读写同一共享变量存在并发问题”,这里的必要条件之一是读写,如果只有读,而没有写,是没有并发问题的。
解决并发问题其实最简单的办法就是让共享变量只有读操作而没有写操作。这个办法如此重要以至于被上升到了一种解决并发问题的设计模式不变性Immutability模式。所谓不变性简单来讲就是对象一旦被创建之后状态就不再发生变化。换句话说就是变量一旦被赋值就不允许修改了没有写操作没有修改操作也就是保持了不变性。
快速实现具备不可变性的类
实现一个具备不可变性的类还是挺简单的。将一个类所有的属性都设置成final的并且只允许存在只读方法那么这个类基本上就具备不可变性了。更严格的做法是这个类本身也是final的也就是不允许继承。因为子类可以覆盖父类的方法有可能改变不可变性所以推荐你在实际工作中使用这种更严格的做法。
Java SDK里很多类都具备不可变性只是由于它们的使用太简单最后反而被忽略了。例如经常用到的String和Long、Integer、Double等基础类型的包装类都具备不可变性这些对象的线程安全性都是靠不可变性来保证的。如果你仔细翻看这些类的声明、属性和方法你会发现它们都严格遵守不可变类的三点要求类和属性都是final的所有方法均是只读的。
看到这里你可能会疑惑Java的String方法也有类似字符替换操作怎么能说所有方法都是只读的呢我们结合String的源代码来解释一下这个问题下面的示例代码源自Java 1.8 SDK我略做了修改仅保留了关键属性value[]和replace()方法你会发现String这个类以及它的属性value[]都是final的而replace()方法的实现就的确没有修改value[],而是将替换后的字符串作为返回值返回了。
public final class String {
private final char value[];
// 字符替换
String replace(char oldChar,
char newChar) {
//无需替换直接返回this
if (oldChar == newChar){
return this;
}
int len = value.length;
int i = -1;
/* avoid getfield opcode */
char[] val = value;
//定位到需要替换的字符位置
while (++i < len) {
if (val[i] == oldChar) {
break;
}
}
//未找到oldChar无需替换
if (i >= len) {
return this;
}
//创建一个buf[],这是关键
//用来保存替换后的字符串
char buf[] = new char[len];
for (int j = 0; j < i; j++) {
buf[j] = val[j];
}
while (i < len) {
char c = val[i];
buf[i] = (c == oldChar) ?
newChar : c;
i++;
}
//创建一个新的字符串返回
//原字符串不会发生任何变化
return new String(buf, true);
}
}
通过分析String的实现你可能已经发现了如果具备不可变性的类需要提供类似修改的功能具体该怎么操作呢做法很简单那就是创建一个新的不可变对象这是与可变对象的一个重要区别可变对象往往是修改自己的属性
所有的修改操作都创建一个新的不可变对象你可能会有这种担心是不是创建的对象太多了有点太浪费内存呢是的这样做的确有些浪费那如何解决呢
利用享元模式避免创建重复对象
如果你熟悉面向对象相关的设计模式相信你一定能想到享元模式Flyweight Pattern利用享元模式可以减少创建对象的数量从而减少内存占用Java语言里面LongIntegerShortByte等这些基本数据类型的包装类都用到了享元模式
下面我们就以Long这个类作为例子看看它是如何利用享元模式来优化对象的创建的
享元模式本质上其实就是一个对象池利用享元模式创建对象的逻辑也很简单创建之前首先去对象池里看看是不是存在如果已经存在就利用对象池里的对象如果不存在就会新创建一个对象并且把这个新创建出来的对象放进对象池里
Long这个类并没有照搬享元模式Long内部维护了一个静态的对象池仅缓存了[-128,127]之间的数字这个对象池在JVM启动的时候就创建好了而且这个对象池一直都不会变化也就是说它是静态的之所以采用这样的设计是因为Long这个对象的状态共有 264 实在太多不宜全部缓存[-128,127]之间的数字利用率最高下面的示例代码出自Java 1.8valueOf()方法就用到了LongCache这个缓存你可以结合着来加深理解
Long valueOf(long l) {
final int offset = 128;
// [-128,127]直接的数字做了缓存
if (l >= -128 && l <= 127) {
return LongCache
.cache[(int)l + offset];
}
return new Long(l);
}
//缓存,等价于对象池
//仅缓存[-128,127]直接的数字
static class LongCache {
static final Long cache[]
= new Long[-(-128) + 127 + 1];
static {
for(int i=0; i<cache.length; i++)
cache[i] = new Long(i-128);
}
}
前面我们在13 | 理论基础模块热点问题答疑中提到Integer String 类型的对象不适合做锁其实基本上所有的基础类型的包装类都不适合做锁因为它们内部用到了享元模式这会导致看上去私有的锁其实是共有的例如在下面代码中本意是A用锁alB用锁bl各自管理各自的互不影响但实际上al和bl是一个对象结果A和B共用的是一把锁
class A {
Long al=Long.valueOf(1);
public void setAX(){
synchronized (al) {
//省略代码无数
}
}
}
class B {
Long bl=Long.valueOf(1);
public void setBY(){
synchronized (bl) {
//省略代码无数
}
}
}
使用Immutability模式的注意事项
在使用Immutability模式的时候需要注意以下两点
对象的所有属性都是final的并不能保证不可变性
不可变对象也需要正确发布
在Java语言中final修饰的属性一旦被赋值就不可以再修改但是如果属性的类型是普通对象那么这个普通对象的属性是可以被修改的例如下面的代码中Bar的属性foo虽然是final的依然可以通过setAge()方法来设置foo的属性age所以在使用Immutability模式的时候一定要确认保持不变性的边界在哪里是否要求属性对象也具备不可变性
class Foo{
int age=0;
int name="abc";
}
final class Bar {
final Foo foo;
void setAge(int a){
foo.age=a;
}
}
下面我们再看看如何正确地发布不可变对象不可变对象虽然是线程安全的但是并不意味着引用这些不可变对象的对象就是线程安全的例如在下面的代码中Foo具备不可变性线程安全但是类Bar并不是线程安全的类Bar中持有对Foo的引用foo对foo这个引用的修改在多线程中并不能保证可见性和原子性
//Foo线程安全
final class Foo{
final int age=0;
final int name="abc";
}
//Bar线程不安全
class Bar {
Foo foo;
void setFoo(Foo f){
this.foo=f;
}
}
如果你的程序仅仅需要foo保持可见性无需保证原子性那么可以将foo声明为volatile变量这样就能保证可见性如果你的程序需要保证原子性那么可以通过原子类来实现下面的示例代码是合理库存的原子化实现你应该很熟悉了其中就是用原子类解决了不可变对象引用的原子性问题
public class SafeWM {
class WMRange{
final int upper;
final int lower;
WMRange(int upper,int lower){
//省略构造函数实现
}
}
final AtomicReference<WMRange>
rf = new AtomicReference<>(
new WMRange(0,0)
);
// 设置库存上限
void setUpper(int v){
while(true){
WMRange or = rf.get();
// 检查参数合法性
if(v < or.lower){
throw new IllegalArgumentException();
}
WMRange nr = new
WMRange(v, or.lower);
if(rf.compareAndSet(or, nr)){
return;
}
}
}
}
总结
利用Immutability模式解决并发问题也许你觉得有点陌生其实你天天都在享受它的战果Java语言里面的String和LongIntegerDouble等基础类型的包装类都具备不可变性这些对象的线程安全性都是靠不可变性来保证的Immutability模式是最简单的解决并发问题的方法建议当你试图解决一个并发问题时可以首先尝试一下Immutability模式看是否能够快速解决
具备不变性的对象只有一种状态这个状态由对象内部所有的不变属性共同决定其实还有一种更简单的不变性对象那就是无状态无状态对象内部没有属性只有方法除了无状态的对象你可能还听说过无状态的服务无状态的协议等等无状态有很多好处最核心的一点就是性能在多线程领域无状态对象没有线程安全问题无需同步处理自然性能很好在分布式领域无状态意味着可以无限地水平扩展所以分布式领域里面性能的瓶颈一定不是出在无状态的服务节点上
课后思考
下面的示例代码中Account的属性是final的并且只有get方法那这个类是不是具备不可变性呢
public final class Account{
private final
StringBuffer user;
public Account(String user){
this.user =
new StringBuffer(user);
}
public StringBuffer getUser(){
return this.user;
}
public String toString(){
return "user"+user;
}
}
欢迎在留言区与我分享你的想法也欢迎你在留言区记录你的思考过程感谢阅读如果你觉得这篇文章对你有帮助的话也欢迎把它分享给更多的朋友