剑指JUC原理-11.不可变设计

news/2024/5/20 7:53:18 标签: 开发语言, java, juc
  • 👏作者简介:大家好,我是爱吃芝士的土豆倪,24届校招生Java选手,很高兴认识大家
  • 📕系列专栏:Spring源码、JUC源码
  • 🔥如果感觉博主的文章还不错的话,请👍三连支持👍一下博主哦
  • 🍂博主正在努力完成2023计划中:源码溯源,一探究竟
  • 📝联系方式:nhs19990716,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬👀

文章目录

    • 日期转换的问题
      • 问题提出
      • 思路 - 同步锁
      • 思路 - 不可变
    • 不可变设计
      • final 的使用
      • 保护性拷贝
      • 享元模式
        • 简介
        • 体现
          • 包装类
      • DIY
      • final原理
        • 设置 final 变量的原理
        • 获取 final 变量的原理
          • 常量池、堆、栈的概念
            • 常量池
    • 无状态

日期转换的问题

问题提出

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

java">SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                try {
                    log.debug("{}", sdf.parse("1951-04-21"));
                } catch (Exception e) {
                    log.error("{}", e);
                }
            }).start();
        }

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

19:10:40.859 [Thread-2] c.TestDateParse - {} 
java.lang.NumberFormatException: For input string: "" 
 at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65) 
 at java.lang.Long.parseLong(Long.java:601) 
 at java.lang.Long.parseLong(Long.java:631) 
 at java.text.DigitList.getLong(DigitList.java:195) 
 at java.text.DecimalFormat.parse(DecimalFormat.java:2084) 
 at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162) 
 at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514) 
 at java.text.DateFormat.parse(DateFormat.java:364) 
 at cn.itcast.n7.TestDateParse.lambda$test1$0(TestDateParse.java:18) 
 at java.lang.Thread.run(Thread.java:748) 
19:10:40.859 [Thread-1] c.TestDateParse - {} 
java.lang.NumberFormatException: empty String 
 at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842) 
 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:2089) 
 at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162) 
 at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514) 
 at java.text.DateFormat.parse(DateFormat.java:364) 
 at cn.itcast.n7.TestDateParse.lambda$test1$0(TestDateParse.java:18) 
 at java.lang.Thread.run(Thread.java:748) 
19:10:40.857 [Thread-8] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951 
19:10:40.857 [Thread-9] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951 
19:10:40.857 [Thread-6] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951 
19:10:40.857 [Thread-4] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951 
19:10:40.857 [Thread-5] c.TestDateParse - Mon Apr 21 00:00:00 CST 178960645 
19:10:40.857 [Thread-0] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951 
19:10:40.857 [Thread-7] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951 
19:10:40.857 [Thread-3] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951 

原因:

因为其内部的一些属性是可变的,所以多个线程访问就会出现问题。

SimpleDateFormat是Java中的日期格式化类,它不是线程安全的。当多个线程同时访问同一个SimpleDateFormat实例时,会导致竞争条件和数据不一致的问题。

SimpleDateFormat内部维护了一个Calendar实例作为工作对象,用于解析和格式化日期。在多线程环境下,多个线程同时调用SimpleDateFormat的方法可能会导致多个线程并发地修改共享的Calendar实例,造成数据混乱。

具体表现为,一个线程正在执行格式化操作,而另一个线程突然调用了parse()方法进行解析,这时两个线程都会对内部的Calendar实例进行修改,导致结果不可预测。

思路 - 同步锁

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

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

思路 - 不可变

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

java">DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                LocalDate date = dtf.parse("2018-10-01", LocalDate::from);
                log.debug("{}", date);
            }).start();
        }

可以看 DateTimeFormatter 的文档:

@implSpec
This class is immutable and thread-safe.

不可变对象,实际是另一种避免竞争的方式。

不可变设计

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

java">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 为例:

java">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 做出了修改:

java">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;
                }
            }
            if (offset > value.length - count) {
                throw new StringIndexOutOfBoundsException(offset + count);
            }
            this.value = Arrays.copyOfRange(value, offset, offset+count);
        }

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

但是却会带来一定的问题,对象创建的太多频繁,为了解决这个问题,所以一般都会关联一个设计模式。

享元模式

简介

定义 英文名称:Flyweight pattern. 当需要重用数量有限的同一类对象时(每次保护性拷贝都需要创建新的字符串,如果有取值相同的对象已经有了,那就可以重用这些对象,而不是每次都创建新的)

体现
包装类

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

java">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);
        }

注意:

  • Byte, Short, Long 缓存的范围都是 -128~127
  • Character 缓存的范围是 0~127
  • Integer的默认范围是 -128~127(最小值不能变、但最大值可以通过调整虚拟机参数-Djava.lang.Integer.IntegerCache.high` 来改变)
  • Boolean 缓存了 TRUE 和 FALSE

DIY

例如:一个线上商城应用,QPS 达到数千,如果每次都重新创建和关闭数据库连接,性能会受到极大影响。 这时预先创建好一批连接,放入连接池。一次请求到达后,从连接池获取连接,使用完毕后再还回连接池,这样既节约了连接的创建和关闭时间,也实现了连接的重用,不至于让庞大的连接数压垮数据库。

java">class Pool {
    // 1. 连接池大小
    private final int poolSize;
    // 2. 连接对象数组
    private Connection[] connections;
    // 3. 连接状态数组 0 表示空闲, 1 表示繁忙
    private AtomicIntegerArray states;
    // 4. 构造方法初始化
    public Pool(int poolSize) {
        this.poolSize = poolSize;
        this.connections = new Connection[poolSize];
        this.states = new AtomicIntegerArray(new int[poolSize]);
        for (int i = 0; i < poolSize; i++) {
            connections[i] = new MockConnection("连接" + (i+1));
        }
    }
    // 5. 借连接
    public Connection borrow() {
        while(true) {
            for (int i = 0; i < poolSize; i++) {
                // 获取空闲连接
                if(states.get(i) == 0) {
                    if (states.compareAndSet(i, 0, 1)) {
                        log.debug("borrow {}", connections[i]);
                        return connections[i];
                    }
                }
            }
            // 如果没有空闲连接,当前线程进入等待
            synchronized (this) {
                try {
                    log.debug("wait...");
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    // 6. 归还连接
    public void free(Connection conn) {
        for (int i = 0; i < poolSize; i++) {
            if (connections[i] == conn) {
                states.set(i, 0);
                synchronized (this) {
                    log.debug("free {}", conn);
                    this.notifyAll();
                }
                break;
            }
        }
    }
}
class MockConnection implements Connection {
    // 实现略
}

使用连接池:

java">Pool pool = new Pool(2);
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                Connection conn = pool.borrow();
                try {
                    Thread.sleep(new Random().nextInt(1000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                pool.free(conn);
            }).start();
        }

输出:

连接1
连接2
borrow MockConnection@1c1832d4
borrow MockConnection@6f6b8a01
wait...
wait...
wait...
free MockConnection@6f6b8a01
borrow MockConnection@6f6b8a01
wait...
wait...
free MockConnection@6f6b8a01
borrow MockConnection@6f6b8a01
wait...
free MockConnection@1c1832d4
borrow MockConnection@1c1832d4
free MockConnection@6f6b8a01
free MockConnection@1c1832d4

以上实现没有考虑:

  • 连接的动态增长与收缩
  • 连接保活(可用性检测)
  • 等待超时处理

对于关系型数据库,有比较成熟的连接池实现,例如c3p0, druid等 对于更通用的对象池,可以考虑使用apache commons pool,例如redis连接池可以参考jedis中关于连接池的实现

final原理

设置 final 变量的原理

理解了 volatile 原理,再对比 final 的实现就比较简单了

java">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,其实是分两步走的,第一步先是分配空间,还没有赋值,初始值是0.如果此时其他线程看到了赋值之前的零,那么变量的使用就是不正确的。

获取 final 变量的原理

在Java中,获取final变量的原理与获取普通变量的原理是一样的,都是在运行时访问内存中的变量值。不同的是,对于final变量,编译器会进行优化,将其存储在常量池中,而非存储在堆或栈中。

当程序需要访问final变量时,实际上是直接从常量池中读取该变量的值,而不是从内存中读取。这样可以提高程序的执行效率,避免了不必要的内存操作。

常量池、堆、栈的概念
常量池
  • 位于方法区(JDK7及之前)或元空间(JDK8及之后)中。

  • 用于存储编译期生成的字面量(Literal)和符号引用(Symbolic Reference)。

  • 存储字符串常量、类和接口的全限定名、字段和方法的名称和描述符等。

  • 常量池的内容在编译时确定,并保存在class文件中,运行时被加载到内存中。

  • 常量池中的数据是不可修改的,可以被多个对象共享。

  • 用于存储对象实例,包括通过new关键字创建的对象。

  • 位于Java虚拟机内存中的一块较大的区域。

  • 堆中的对象由垃圾回收器自动管理,通过垃圾回收机制来释放不再使用的对象。

  • 对象在堆中分配内存,对象的引用存储在栈中。

  • 位于线程私有的内存区域。

  • 每个线程都有自己的栈,用于存储局部变量、方法调用和方法返回等。

  • 栈中的数据是按照“后进先出”的原则进行操作。

  • 栈的大小在虚拟机启动时就可以固定,随着线程的创建和销毁而动态改变。

简单来说,常量池主要用于存储编译期生成的字面量和符号引用,堆用于存储对象实例,栈用于存储方法调用和局部变量。常量池的内容是不可修改的,堆和栈中的数据是动态变化的,并且随着程序的执行而进行内存分配和回收。

无状态

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

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


http://www.niftyadmin.cn/n/5151119.html

相关文章

[动态规划] (五) 路径问题: LeetCode 62.不同路径

[动态规划] (五) 路径问题: LeetCode 62.不同路径 文章目录 [动态规划] (五) 路径问题: LeetCode 62.不同路径题目解析解题思路状态表示状态转移方程初始化和填表返回值 代码实现总结 62. 不同路径 题目解析 (1) 机器人从左上角到右下角有多少方法 (2) 机器人只能向左或者向右…

STL(第四课):deque

STL deque是标准模板库&#xff08;STL&#xff09;中的一个容器&#xff0c;deque是“double-ended queue”的缩写&#xff0c;它是一种双端队列的数据结构&#xff0c;允许在两端进行插入和删除操作。 STL deque中的元素存储在连续的内存块中&#xff0c;一般由多个缓冲区组成…

Java自学第4课:Java数组,类,对象

1 一维数组的创建和使用 2种创建形式&#xff1a; &#xff08;1&#xff09;先声明&#xff0c;再用new分配内存 &#xff08;2&#xff09;声明的同时分配内存 2种幅值形式 &#xff08;1&#xff09;用new{}赋值 &#xff08;2&#xff09;用{}赋值 如果不使用的话&a…

Pyhotn: Mac安装selenium和chromedriver-119

1.0 安装selenium 终端输入&#xff1a; pip install selenium 查看版本&#xff1a; pip show selenium2.0 安装chromedriver 查看chrome版本 网上大多数是&#xff0c;基本到114就停了。 https://registry.npmmirror.com/binary.html?pathchromedriver/ 各种搜索&#…

操作系统备考学习 day12 (第五章)

操作系统备考学习 day12 第五章 &#xff08;输入/输出&#xff09;I/O管理5.1 I/O管理概述5.1.1 I/O设备I/O设备的分类 5.1.2 I/O控制器I/O设备的电子部件 5.1.3 I/O控制方式程序直接控制方式中断驱动方式DMA方式DMA控制器通道控制方式 5.1.4 I/O软件层次结构用户层软件设备独…

【C++那些事儿】类与对象(1)

君兮_的个人主页 即使走的再远&#xff0c;也勿忘启程时的初心 C/C 游戏开发 Hello,米娜桑们&#xff0c;这里是君兮_&#xff0c;我之前看过一套书叫做《明朝那些事儿》&#xff0c;把本来枯燥的历史讲的生动有趣。而C作为一门接近底层的语言&#xff0c;无疑是抽象且难度颇…

双十一首战捷报丨Kaadas凯迪仕智能锁品类全网第一 获央视二套采访报道 尽显行业头部品牌风采

2023“双十一”购物狂欢节在如火如荼进行中&#xff0c;智能门锁品类作为智能家居安全体系的重要组成部分&#xff0c;在今年活动中又一次迎来了大卖&#xff0c;智能锁成为了人们购物车里的热门商品。 延续往年势头&#xff0c;Kaadas凯迪仕智能锁今年双11在各大电商平台再次取…

[动态规划] (六) 路径问题 LeetCode 63.不同路径II

[动态规划] (六) 路径问题: LeetCode 63.不同路径II 文章目录 [动态规划] (六) 路径问题: LeetCode 63.不同路径II题目解析解题思路状态表示状态转移方程初始化和填表返回值 代码实现总结 63. 不同路径 II 题目解析 (1) 机器人从左上角移动到右下角 (2) 机器人只能向右或者向…