9 、不可变性和享元模式以及final的原理

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

本篇博客主要内容是关于JDK中不可变类的使用、设计以及无状态类设计,不可变类和无状态类能够保证我们类的线程安全性,同时介绍了享元模式,不可变类的设计都是要关联享元模式的,享元模式能够有效地节省内存,基于享元模式,我们实现一个简单的自定义数据库连接池的设计。


日期转换的问题

问题提出
下面的代码在运行时,由于 SimpleDateFormat 不是线程安全的

@Slf4j(topic = "c.Test23")
public class Test36 {

    public static void main(String[] args) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        for (int i = 0; i <50; i++) {
            new Thread(() -> {
                try {
                    log.debug("{}", sdf.parse("1951-04-21"));
                } catch (Exception e) {
                    log.error("{}", e);
                }
            }).start();
        }
    }
}

有很大几率出现 java.lang.NumberFormatException 或者出现不正确的日期解析结果,例如:

16:25:59.033 [Thread-1] ERROR c.Test23 - {}
java.lang.NumberFormatException: multiple points
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
	at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.lang.Double.parseDouble(Double.java:538)
	at java.text.DigitList.getDouble(DigitList.java:169)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at cn.itcast.text.Test36.lambda$main$0(Test36.java:15)
	at java.lang.Thread.run(Thread.java:748)
16:25:59.039 [Thread-4] ERROR c.Test23 - {}
java.lang.NumberFormatException: For input string: "E.2404"
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
	at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.lang.Double.parseDouble(Double.java:538)
	at java.text.DigitList.getDouble(DigitList.java:169)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at cn.itcast.text.Test36.lambda$main$0(Test36.java:15)
	at java.lang.Thread.run(Thread.java:748)
16:25:59.035 [Thread-5] DEBUG c.Test23 - Sat Apr 21 00:00:00 CST 1951

思路 - 同步锁
这样虽能解决问题,但带来的是性能上的损失,并不算很好:

@Slf4j(topic = "c.Test23")
public class Test36 {

    public static void main(String[] args) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        for (int i = 0; i <50; i++) {
            new Thread(() -> {
                try {
                    synchronized (sdf){
                        log.debug("{}", sdf.parse("1951-04-21"));
                    }
                } catch (Exception e) {
                    log.error("{}", e);
                }
            }).start();
        }
    }
}

思路 - 不可变
如果一个对象在不能够修改其内部状态(属性),那么它就是线程安全的,因为不存在并发修改啊!这样的对象在Java 中有很多,例如在 Java 8 后,提供了一个新的日期格式化类:

@Slf4j(topic = "c.Test23")
public class Test36 {

    public static void main(String[] args) {
        DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                LocalDate date = dtf.parse("2022-5-25", LocalDate::from);
                log.debug("{}", date);
            }).start();
        }
    }
}

可以看 DateTimeFormatter 的文档:
在这里插入图片描述
不可变对象,实际是另一种避免竞争的方式。

不可变设计

另一个大家更为熟悉的 String 类也是不可变的,以它为例,说明一下不可变设计的要素

public final class String
            implements java.io.Serializable, Comparable<String>, CharSequence {
        /** The value is used for character storage. */
        private final char value[];
        /** Cache the hash code for the string */
        private int hash; // Default to 0

        // ...

    }

final 的使用
发现该类、类中所有属性都是 final 的

  • 属性用 final 修饰保证了该属性是只读的,不能修改
  • 类用 final 修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性

保护性拷贝
但有同学会说,使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那么下面就看一看这些方法是如何实现的,就以 substring 为例:

public String substring(int beginIndex) {
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        int subLen = value.length - beginIndex;
        if (subLen < 0) {
            throw new StringIndexOutOfBoundsException(subLen);
        }
        return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
    }

发现其内部是调用 String 的构造方法创建了一个新字符串,再进入这个构造看看,是否对 final char[] value 做出了修改:

public String(char value[], int offset, int count) {
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (count <= 0) {
            if (count < 0) {
                throw new StringIndexOutOfBoundsException(count);
            }
            if (offset <= value.length) {
                this.value = "".value;
                return;
            }
        }
        // Note: offset or count might be near -1>>>1.
        if (offset > value.length - count) {
            throw new StringIndexOutOfBoundsException(offset + count);
        }
        this.value = Arrays.copyOfRange(value, offset, offset+count);
    }

结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】

享元模式

前面介绍了不可变对象的设计,但是为了保证对象的不可变性,需要使用保护性拷贝的机制,在对 对象进行修改的时候,并不是直接修改对象,而是直接创建一个新的对象,虽然这样,避免了多线程的并发问题,但是也带来一定的弊端,就是每次都要创建新的对象,对象的个数可能不比较多,因此对于不可类的设计都要关联一个设计模式,也就是享元模式。

享元模式就是最小化内存的使用,对相同的对象进行共享,JDK很多运行享元模式

包装类

在JDK中 Boolean、Byte、Short、Integer、Long、Character等包装类提供了valueOf()方法,例如Long的valueOf会缓存-128-127之间的Long对象,在这个范围之间会重用对象,大于这个范围才会创建Long对象:

public static Long valueOf(long l) {
        final int offset = 128;
        if (l >= -128 && l <= 127) { // will cache
            return LongCache.cache[(int)l + offset];
        }
        return new Long(l);
    }

//Long在初始化的时候,就会创建一个256大小的数组,把-128-127放进数组中

private static class LongCache {
        private 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);
        }
    }

注意
Byte、Short、Long缓存的范围时-128到127
Character的缓存范围时0到127
Integer的默认范围是 -128到127,最小值不能变,但是最大值可以通过虚拟机参数进行调整:-Djava.lang.Integer.IntegerCache.high来改变
Boolean缓存了TRUE和FALSE

String串池

JVM有个字符串常量池,我的这篇博客对StringTable进行了详细的说明https://blog.csdn.net/m0_45364328/article/details/124569863

BIgDecimal和BigInteger

以BIgDecimal为例:它也是修改时每次都new一个BigDecimal,因此BigDecimal也是保护性拷贝避免BIgDecimal的改变

  private static BigDecimal add(BigInteger fst, int scale1, BigInteger snd, int scale2) {
        int rscale = scale1;
        long sdiff = (long)rscale - scale2;
        if (sdiff != 0) {
            if (sdiff < 0) {
                int raise = checkScale(fst,-sdiff);
                rscale = scale2;
                fst = bigMultiplyPowerTen(fst,raise);
            } else {
                int raise = checkScale(snd,sdiff);
                snd = bigMultiplyPowerTen(snd,raise);
            }
        }
        BigInteger sum = fst.add(snd);
        return (fst.signum == snd.signum) ?
                new BigDecimal(sum, INFLATED, rscale, 0) :
                valueOf(sum, rscale, 0);
    }

之前的一篇博客https://blog.csdn.net/m0_45364328/article/details/124962084中,我们介绍了原子引用实现无锁的方式(cas)来解决并发下的转账问题时,有这么一段代码:

class DecimalAccountSafeCas implements DecimalAccount {
    AtomicReference<BigDecimal> ref;

    public DecimalAccountSafeCas(BigDecimal balance) {
        ref = new AtomicReference<>(balance);
    }

    @Override
    public BigDecimal getBalance() {
        return ref.get();
    }

    @Override
    public void withdraw(BigDecimal amount) {
        while (true) {
            BigDecimal prev = ref.get();
            BigDecimal next = prev.subtract(amount);
            if (ref.compareAndSet(prev, next)) {
                break;
            }
        }
    }
}

我们说BigDecimal是线程安全,但是为什么还要用 AtomicReference 保护起来,BigDecimal的单个方法是线程安全的,但是多个方法的组合就不是线程安全了:

BigDecimal prev = ref.get();//取值
BigDecimal next = prev.subtract(amount);//减法

虽然取值和减法都是原子操作,但是组合在一起就不是原子操作了,这也是为什么要用原子引用包起来的原因。

AtomicReference底层实际上是在赋值的时候,使用了CAS锁来解决并发问题,它的成员变量value是valitale修饰的,保证了多线程之间的可见性,一旦有一个线程对value进行修改了,其他线程就能从主存中读到,通过compareAndSet进行赋值

final原理

设置final原理

public class TestFinal {
    final int a = 20;
}

字节码:

         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        20
         7: putfield      #2                  // Field a:I
         < -- 写屏障
        10: return

发现final变量的赋值是通过putfield指令完成的,这个指令会在后面加上写屏障,保证其他线程在读到它的值的时候,不会出现0的情况

写屏障
保证了写屏障之前的指令不会重排序到写屏障之后
保证了写屏障之前所有的修改操作会同步到主存,也就是说对其他线程可见

获取final


public class TestFinal {
    final static int A = 10;
}

class UseFinal{
    public void  test(){
        System.out.println(TestFinal.A);   
    }
}

看下test的字节码:

0 getstatic #2 <java/lang/System.out>
3 bipush 10
5 invokevirtual #4 <java/io/PrintStream.println>
8 return

去掉final


public class TestFinal {
     static int A = 10;
}

class UseFinal{
    public void  test(){
        System.out.println(TestFinal.A);
    }
}

test字节码:

0 getstatic #2 <java/lang/System.out>
3 getstatic #3 <cn/itcast/text/TestFinal.A>
6 invokevirtual #4 <java/io/PrintStream.println>
9 return

首先看没有final时
getstatic #3 <cn/itcast/text/TestFinal.A>
意思是取TestFinal中的A(10),说明这个10是从外面拿的,TestFinal中的10是共享的
在这里插入图片描述
而有final时:
bipush 10:并没有取TestFinal中的10,而是直接往操作数栈中push了一个10,这个10是直接赋值了一份TestFinal的10,那么就说明TestFinal的10是线程安全的

较大数值:常量池
不加final读取堆内存,性能较低

无状态

在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的

因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值