学习笔记:Java 并发编程⑤_不可变

7 篇文章 0 订阅

若文章内容或图片失效,请留言反馈。

部分素材来自网络,若不小心影响到您的利益,请联系博主删除。



写这篇博客旨在制作笔记,方便个人在线阅览,巩固知识。无他用。

博客的内容主要来自视频内容和资料中提供的学习笔记。当然,在此基础之上也增删了一些内容。


参考书籍

  • 《实战 JAVA 高并发程序设计》 葛一鸣
  • 《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明

参考文章


系列目录



本章内容


不可变类的使用不可变类的设计无状态类的设计


1. 不可变类的使用


1.1.日期转换问题


@Slf4j(topic = "c.TestSimpleDataFormat")
public class TestSimpleDataFormat {
    public static void main(String[] args) {
        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();
        }

    }
}

上面的代码在运行时,由于 SimpleDateFormat 不是线程安全的,有很大几率出现 java.lang.NumberFormatException 或者出现不正确的日期解析结果


1.2.解决方法:加锁


@Slf4j(topic = "c.TestSimpleDataFormat")
public class TestSimpleDataFormat {
    public static void main(String[] args) {
		test_1();
    }

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

1.3.解决方法-不可变类


private static void test_2() {
    DateTimeFormatter sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            TemporalAccessor parse = sdf.parse("1951-04-21");
            log.debug("{}", parse);
        }).start();
    }
}

2.不可变类的设计


不可变对象就是一经创建后,其对象的内部状态不能被修改。

摘抄自微信公众号程序员 Cxuan上的 面试题大全_程序员 cxuan 编写.pdf 的内容

不可变对象需要遵守下面几条原则

  • 不可变对象的内部属性都是 final 修饰的
  • 不可变对象的内部属性都是 private 修饰的
  • 不可变对象不能提供任何可以修改内部状态的方法setter 方法也不行
  • 不可变对象不能被继承和扩展


不可变对象不是真的不可变可以通过反射来对其内部的属性和值进行修改不过一般我们不这样做


2.1.String 类的设计


String 类也是不可变的,以它为例,说明一下不可变设计的要素

注意:在 Java 9 之后,StringStringBuilderStringBuffer 的实现改用 byte 数组存储字符串。

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 修饰的类,则保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性。final 修饰的方法也是不可以被重写的(但是可以被重载)。

String 不可变的原因

  1. String final 修饰的说明不可被继承
  2. private final char value[]保存字符串的数组。被 final 修饰,表明该数组的地址不可变。虽然 final 修饰的这个数组中的内容是可变的,但是被 private 修饰的属性和方法,只能在当前类的内部可见,而 String 并没有提供修改/暴露这个字符串的方法。

这里我们再看它的众多构造方法中的一个

public String(char value[]) {
	this.value = Arrays.copyOf(value, value.length);
}

要改变 String 中的数组的内容,只能是重新创建一个 String 对象。
String 对象成功创建后是放在运行时常量池中的字符串常量池,JDK 7 之前是位于方法区的永久代里的,JDK 7 之后把它移到了堆中。


2.2.保护性拷贝


使用字符串时,有一些跟修改相关的方法(比如 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;
        }
    }
    if (offset > value.length - count) {
        throw new StringIndexOutOfBoundsException(offset + count);
    }
    this.value = Arrays.copyOfRange(value, offset, offset + count);
}

结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。

这种通过创建副本对象来避免共享的手段称之为 保护性拷贝defensive copy


2.3.享元模式


String 等不可变类通过使用保护性拷贝的方式来避免了资源的共享

但是上述这种方式有一个问题:那就是频繁创建对象会占用大量的内存

为了解决这个问题,我们一般会给这种不可变类关联一个设计模式:享元模式


英文名称:Flyweight pattern

使用场景:需要重用数量有限的同一类对象时

wikipedia : A flyweight is an object that minimizes memory usage by sharing as much data aspossible with other similar objects

出自 “Gang of Fourdesign patterns

归类 Structual patterns

一言以蔽之,就是 “尽量通过共享实例来避免 new 出实例

当需要某个实例时,并不总是通过 new 关键字来生成实例,而是尽量共用已经存在的实例,这就是 Flyweight 的核心内容。


  • 注意
    不可变类是线程安全的,但只是它们内部的单个方法是线程安全的,我们并不能保证它们内部的多个方法的组合是线程安全的。
    比如 获取值、设置值、减法 的三个操作,它们每个都是原子操作,但是我们并不能保证这三个之间的组合是原子的。
    一般原子化的 CAS 指令解决线程安全问题。
    这也是我们在之前的博客里,对同样是不可变类的 BigDecimal,用原子引用类对其进行线程保护的原因。

2.3.1.包装类


JDKBooleanByteShortIntegerLongCharacter 等包装类提供了 valueOf 方法,例如 LongvalueOf 会缓存 -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);
}

注意

  • ByteShortLong 缓存的范围都是 -128~127
  • Character 缓存的范围是 0~127
  • Integer 的默认范围是 -128~127,最小值不能变,但最大值可以变
    最大值可以通过调整虚拟机参数 -Djava.lang.Integer.IntegerCache.high 来改变
  • Boolean 缓存了 TRUEFALSE

2.3.2.String 串池


String 也用到了享元模式,也就是字符串常量池,大致类似于缓存区,是 JVM 为节省内存开销的一个优化。

字符串常量池中保存的是字符串对象的引用,这个字符串对象引用是指向堆中的字符串对象的。

public class TestString_0 {
    public static void main(String[] args) {
        String s1 = new String("Faiz");
    }
}

使用命令:javap -v TestString_0.class,控制台打印如下信息

// 这里只保留了重要的部分

Code:
  stack=3, locals=2, args_size=1
     0: new           #2                  // class java/lang/String
     3: dup
     4: ldc           #3                  // String Faiz
     6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
     9: astore_1
    10: return

这里补充一下指令的相关知识

  • new:创建类实例
  • dup:复制栈顶一个数值,并将复制值重新压入栈顶
  • ldc:将一个常量从常量池中加载到操作数栈
  • invokespecial:用于调用一些需要特殊处理的实例方法(包括实例初始化方法、私有方法和父类方法)
  • astore:将一个数值从操作数栈存储到局部变量表
  • return:方法返回指令之一,专供 void 方法、实例初始化方法、类和接口的初始化方法的使用

推荐阅读博客字节码 new 后为何 dup

现在开始分析 String s1 = new String("Faiz"); 这句代码。
String s1 声明了一个 String 类型的变量 s1
new String("Faiz") 是在堆中创建了一个对象,并且会给这个对象赋值。此外,如果常量池中不存在字符串对象 “Faiz”,其会在堆中创建一个 “Faiz” 字符串对象;如果常量池中存在字符串对象 “Faiz”,则直接调用即可。

不过 javap 这个命令打印出的信息里,在方法信息的 Code 里似乎是看不到字符串对象 “Faiz” 装入到常量池的这个动作的指令的。为新创建的实例对象赋值这个操作倒是可以看到的。

我看到 JavaGuide 上说 ldc 指令从常量池中读取指定字符串对象时,如果常量池中不存在该字符串对象,则会在堆中创建字符串对象,并且将该字符串对象的引用保存到字符串常量池中。但我并不是很确定这个说法是否正确。

参考文章JVM:ldc 指令_riscv 汇编学习

ldc 指令功能:
    ldc 指令将 intfloat、或者一个类、方法类型或方法句柄的符号引用、还可能是 String 型常量值从常量池中推送至栈顶。
    ldc 指令可以加载 String、方法类型或方法句柄的符号引用。
    但是如果要加载 String、方法类型或方法句柄的符号引用,则会在类连接过程中重写 ldc 字节码指令为虚拟机内部使用的字节码指令 _fast_aldc


这里再提一个比较常问的面试题

以下参考自 《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明

运行时常量池是方法区的一部分。HotSpot 虚拟机从 JDK 7 开始逐步 “去永久代” 的计划,并在 JDK 8 中完全使用元空间来代替永久代。

String::intern() 是一个本地方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象的引用;否则,会将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。

JDK 6 或更早之前的 HotSpot 虚拟机中,常量池都是分配在 永久代 中。
JDK 7 起,原本存放在永久代的字符串常量池被移至 Java 堆 之中。

public class RuntimeConstantPool {
    public static void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);
        
        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }
}

这段代码在 JDK 6 中运行,会得到两个 false
JDK 7 中运行,会得到一个 true 和一个 false

产生差异的原因是:

  • JDK 6 中,intern() 方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由 StringBuilder 创建的字符串对象实例在 Java 堆上,所以必然不可能是同一个引用,结果将返回 false
  • 而在 JDK 7intern() 方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到 Java 堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此 intern() 返回的引用和由 StringBuilder 创建的那个字符串实例就是同一个。
    而对 str2 比较返回 false,这是因为 “java” 这个字符串在执行 String-Builder.toString() 之前就已经出现过了 (它是在加载 sun.misc.Version 这个类的时候进入常量池的),字符串常量池中已经有它的引用,不符合 intern() 方法要求 “首次遇到” 的原则,“计算机软件” 这个字符串则是首次出现的,因此结果返回 true

推荐阅读博客https://javaguide.cn/java/basis/java-basic-questions-02.html#string

该博客中对于 String 相关的概念介绍的非常详细,有许多示例代码和分析。


2.3.3.自定义连接池


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

Pool.java

@Slf4j(topic = "c.Pool")
public 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("等待中 ... ...");
                    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;
            }
        }
    }
}

MockConnection.java

public class MockConnection implements Connection {
    private String name;

    public MockConnection(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "MockConnection{" +
                "name='" + name + '\'' +
                '}';
    }

	// 剩下的都是继承自 Connection 的方法了
}

Test_DIY_Pool.java

@Slf4j(topic = "c.Test_DIY_Pool")
public class Test_DIY_Pool {
    public static void main(String[] args) {
        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();
        }
    }
}

输出结果

18:26:27.379 [Thread-2] DEBUG c.Pool - 等待中 ... ...
18:26:27.379 [Thread-1] DEBUG c.Pool - 获取当前连接:borrow MockConnection{name='连接2'}
18:26:27.389 [Thread-4] DEBUG c.Pool - 等待中 ... ...
18:26:27.389 [Thread-3] DEBUG c.Pool - 等待中 ... ...
18:26:27.379 [Thread-0] DEBUG c.Pool - 获取当前连接:borrow MockConnection{name='连接1'}
18:26:27.937 [Thread-1] DEBUG c.Pool - 归还当前连接:free MockConnection{name='连接2'}
18:26:27.937 [Thread-4] DEBUG c.Pool - 等待中 ... ...
18:26:27.937 [Thread-3] DEBUG c.Pool - 获取当前连接:borrow MockConnection{name='连接2'}
18:26:27.937 [Thread-2] DEBUG c.Pool - 等待中 ... ...
18:26:27.961 [Thread-0] DEBUG c.Pool - 归还当前连接:free MockConnection{name='连接1'}
18:26:27.961 [Thread-4] DEBUG c.Pool - 等待中 ... ...
18:26:27.961 [Thread-2] DEBUG c.Pool - 获取当前连接:borrow MockConnection{name='连接1'}
18:26:28.825 [Thread-2] DEBUG c.Pool - 归还当前连接:free MockConnection{name='连接1'}
18:26:28.825 [Thread-4] DEBUG c.Pool - 获取当前连接:borrow MockConnection{name='连接1'}
18:26:28.906 [Thread-3] DEBUG c.Pool - 归还当前连接:free MockConnection{name='连接2'}
18:26:28.991 [Thread-4] DEBUG c.Pool - 归还当前连接:free MockConnection{name='连接1'}

当然,这个连接池还有许多不足的地方,仅做演示用。

以上实现没有考虑:连接的动态增长与收缩、连接保活(可用性检测)、等待超时处理、分布式 hash

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


2.4.final 原理


2.4.1.设置 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 的情况


2.4.2.获取 final 变量的原理


JDK 8 之前的版本中,static 修饰的变量是存储在方法区中。在 JDK 8 中,静态变量是存储在堆中的。

static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
如果 static 变量是 final 修饰的基本类型 / 字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成

访问类的 static final 静态常量(基本类型和字符串) 不会触发初始化


TestFinal.java

public class TestFinal {
    final static int A1 = 10;
    final static int B1 = Short.MAX_VALUE + 1;

    static int A2 = 12;
    static int B2 = Short.MAX_VALUE + 2;

    final int a = 20;
    final int b = Integer.MAX_VALUE;
}

UseFinal.java

class UseFinal {
    public void test() {
        System.out.println(TestFinal.A1);
        System.out.println(TestFinal.B1);
        System.out.println(TestFinal.A2);
        System.out.println(TestFinal.B2);
        System.out.println(new TestFinal().a);
        System.out.println(new TestFinal().b);
    }
}

编译 UseFinal.java 之后,查看其字节码

// 省略掉了许多东西

BIPUSH 10
LDC 32768

GETSTATIC org/example/chapter07/testFinal/TestFinal.A2 : I
GETSTATIC org/example/chapter07/testFinal/TestFinal.B2 : I

BIPUSH 20
LDC 2147483647

显然,有 final 修饰的变量,其赋值操作是直接在栈上的、或常量池的。
没有 final 修饰的静态变量,其赋值操作是在堆内存中的,调用的是共享内存。

推荐博客深入理解 final 关键字


3.无状态


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

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


4.本章小结


  • 不可变类的使用
  • 不可变类的设计
  • 原理方面
    • final
  • 模式方面
    • 享元

  • 6
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值