首先说结论,String 是线程安全的,不会造成多线程读写冲突问题。
原因如下:String 不是基础类型,而是一个不可变类,我们无法改变一个 String 对象的内容,而只能创建一个新的 String 对象。atomic 包是一个工具包,并不能替代原来的基础类型和对象的使用,因此用 atomic 去封装 String 没有任何用处。atomic 包中常用的类有 AtomicInteger、AtomicIntegerArray、AtomicStampedReference(在 AtomicReference 的基础上加入了一个版本号,可避免对复杂数据结构(比如可复用节点的链表)使用 AtomicReference 时引发的 ABA 问题),常用的方法有 compareAndSet()、getAndIncrement()。
Atomic 原子类用的不是传统的锁机制(悲观锁),而是用的无锁化的 CAS 机制(乐观锁),通过 CAS 机制保证多线程修改的安全性,CAS 的全称是:Compare and Set,也就是先比较再设置的意思(有的地方也叫 Compare and Swap,Compare and Swap 本质是一条硬件指令,由操作系统和硬件保证原子执行,由于 Java 不能直接操作硬件和内存,所以它是通过 JNI 调用其他语言写的硬件操作指令来实现的)。
悲观锁:假设冲突一定会发生,因此在访问数据前就先加锁(如 synchronized、ReentrantLock、数据库的行级锁(SELECT … FOR UPDATE)、表级锁等),确保操作期间数据是被独占的,其他线程需等待锁释放;缺点为可能导致线程阻塞、死锁。适用于需要确保强一致性的场景,比如转账时,通过行级锁锁定账户记录,确保同一时间仅一个线程能修改余额。严格来说,只有数据库层面提供的锁机制才能真正保证数据访问的排他性,因为即使在本系统的数据访问层中实现了加锁机制,也无法保证外部系统不会修改数据。
关于在 MySQL 中使用 SELECT … FOR UPDATE:
(I)需要关闭自动提交模式(set autocommit = 0;),然后手动控制事务(begin; 开始,commit; 提交);
(II)后面开始的事务必须也用 SELECT … FOR UPDATE 格式操作同一数据,它才会等待前面的事务结束;
(III)只有明确指定主键或者索引列进行条件查询,才会使用行级锁(查无结果时不会锁), 否则会用表级锁。1
2
3
4
5begin; -- 或者 start transaction;
select status from t_items where id = 1 for update; -- status 为 1 时商品才能被下单
insert into t_orders(id, goods_id) values(null, 1);
update t_items set status = 2;
commit;乐观锁:假设发生冲突的概率很低,不加锁直接操作,只在提交更新时检查是否有冲突(如 CAS 算法、Atomic 类、通过增加版本号或时间戳字段验证数据一致性等),若发生冲突,则回滚或重试;缺点为在写操作过多时效率可能降低。适用于评论计数、配置更新这类读多写少的场景,比如更新库存时,先为表增加一个数字类型的 version 字段,查询时将 version 一同读出,提交更新时比较表中当前的 version 值与之前读到的 version 值是否相等,相等则更新,否则认为是过期数据,每成功更新一次,对 version 值加一。
1.String 的不可变特性
String 是被 final 修饰的,无论调用 String 的什么方法,均无法修改 this 对象的状态。当确实需要修改 String 的值时,它的办法是构造一个新的 String 返回,这与可以直接修改原值的基础类型大不相同。其本质是 String 对象中的 value[] 字符数组不可变,而声明的 String 类型的变量指向的引用是可以变的。
1 | public static void main(String[] args) { |
其实不光是 String 对象,Java 中的很多对象都符合上述不可变状态的特性,比如 Integer、Double、Long 等所有原生类型的包装器类型所创建的对象,也都是不可变的。那么明明可以直接修改 this 对象,为何还要大费周章地构造一个全新的对象返回呢?
2.不可变对象的优点
2.1 对并发友好
提到多线程并发,最让人苦恼的莫过于对共享资源的访问冲突,目前在大多数语言中,对多线程冲突问题常采用同步的方式解决,比如 Java 中的 synchronized 关键字和 Lock 对象等机制,但这个方式有个弊端:冲突能否被解决,取决于程序员对共享资源加锁和解锁的时机对不对。所以还有另一个方向,就是从多线程冲突的原因——共享资源上入手。
假设程序中完全没有共享资源,那自然就不存在访问冲突了,Java 中的 ThreadLocal 机制就是利用了这一理念,ThreadLocal 就是给每个线程分配一份数据副本,通过将数据存储到线程内的 ThreadLocalMap 来实现线程隔离,每个线程只能访问和修改自己的副本,从而避免多线程竞争。ThreadLocalMap 是默认内置在每个 Java 线程(Thread 类)中的一个类似哈希表的键值对数组,初始值为 null,仅在首次调用 ThreadLocal.set() 或 ThreadLocal.get() 时才会被创建,避免不必要的内存占用。
- 一个 ThreadLocal 实例只能存储一个值,当在一个线程内使用了一个或多个不同的 ThreadLocal 实例时,这些 ThreadLocal 实例的数据副本会被存储在同一个 ThreadLocalMap 中,因此存在哈希冲突的可能,通常用开放地址法(线性探测)解决。
键(Key)为 ThreadLocal 实例:弱引用(ThreadLocal 实例本身为强引用,但作为 Key 时会用弱引用包装)。
值(Value)为通过 set 方法存储的数据:强引用,一定要记得调用 remove() 方法及时清理旧值(在 finally 块中),否则可能出现内存泄漏;如果使用了线程池,还可能存在线程没销毁就被复用,导致旧数据被新任务误用的问题。 - Java 的四种引用类型(目的是提供更灵活的内存管理机制,在保证程序稳定的同时减少手动管理内存的负担):
强引用(StrongReference):永不自动回收,用 new 创建的对象默认都是强引用,需显式置为 null 才能解除。
软引用(SoftReference):内存不足时(即将发生 OOM 前)才会被回收,常用于实现内存敏感型缓存。
弱引用(WeakReference):发现后立即回收,常用于临时的对象映射关系,防止因疏忽导致的内存泄漏。
虚引用(PhantomReference):无法用 get() 获取对象实例,通过 ReferenceQueue 在对象被回收后触发清理动作。
1 | public class ThreadLocalDemo { |
ThreadLocal 的应用场景有(需跟线程强绑定的场景):
- 保存数据库连接:最典型的就是 Spring 的事务管理,保证在同一个线程里不管调用多少层方法,拿到的都是同一个数据库连接,这样事务才会生效。
- 保存用户会话:比如在 Spring Security 的 SecurityContextHolder 中,为每个请求存储独立的用户身份。
- 保存线程不安全的工具类:比如为每个线程创建独立的 SimpleDateFormat 实例,避免多线程竞争。
ThreadLocal 的特性决定了它不能用于存储全局共享变量,但大多数时候,线程间是需要全局共享变量来互通信息的,那能否让共享资源自诞生之后就不再变更呢?答案是可以,不可变对象就是这样一种对象,天生支持多线程间共享。
某个线程想要修改共享资源 A 的状态时,先在本线程中构造一个新状态的共享资源 B,待 B 构造完成后,再用 B 替换 A,由于对引用赋值这个操作是原子性的,所以不会造成线程冲突。但是需要注意可见性问题,若想要 B 替换 A 之后,其他所有线程能实时感知到变化,可用 volatile 关键字保证可见性和有序性。
1 | public class Test { |
值得注意的是,线程安全需同时考虑原子性、可见性和有序性问题,所以网上常说的不可变对象是线程安全的,其实并不严谨。所有的函数式编程语言,如 Lisp、Erlang 等,都从语法层面保证了只能使用不可变对象,所以所有函数式编程语言天生对并发友好,这也是在一些高并发场景中,函数式编程语言更受青睐的原因。
2.2 易于缓存
当一个对象被频繁访问,而生成该对象的开销较大时,则需要进行缓存,比如存入一个缓存集合(如线程安全容器 ConcurrentHashMap)。但使用缓存就不得不面对缓存污染问题,即缓存对象的状态有可能被上层业务代码修改,这会导致业务数据之间互相干扰,通常的解决方案是返回原始缓存对象的一个深拷贝,这样无论上层业务代码对缓存对象如何修改,均不会对缓存本身造成影响。
但是深拷贝毕竟有额外的性能开销,此时如果缓存的是不可变对象,就皆大欢喜了。因为你可以放心大胆的把缓存对象的引用返回给上层代码使用,因为无论上层代码怎样操作,它也无法修改一个不可变对象的状态,这也就天然规避了缓存污染问题。
3.不可变对象的局限
3.1 编程思维的转变
如果所有对象都被设计为不可变的,等价于使用函数式编程思维,函数式编程具有声明式、高阶函数、函数没有 side effect、只有值而没有变量、用递归而不用迭代等特点,编程思维上的变化并非所有程序员都能很好的适应,如果适应不了,强行推广只会适得其反,况且 Java 本身也并不是纯粹的函数式编程语言。
命令式编程:指令清晰,在什么时间(When)、做什么事情(What)、怎么做(How),都描述得很清楚,命令式编程就是对硬件操作的抽象,计算机只需遵循指令一步步完成即可,执行过程中可能出现异常。
声明式编程:只描述要做什么事(What),不描述具体怎么做(其实细节仍然是用命令式的编程风格来实现)。声明式编程最知名的就是 SQL 了,除此以外,Java 8 中的 stream 写法也是声明式。声明式的代码更清爽,是把一个问题抽象后的表达,以声明式的方式来描述问题和编程,能简化程序员的工作,甚至一些业务人员都可以使用。
3.2 性能上的额外开销
由于修改不可变对象时都会生成一个新对象,如果设计和使用不当的话,可能形成性能瓶颈点。有人可能就想到了用 StringBuffer(线程安全)或 StringBuilder(线程不安全)来解决这个问题,这两个类都是对对象本身直接进行操作,不会生成新的对象。
但是不必过于担心性能问题,一方面内存拷贝速度极快,另外也并非所有额外的性能开销都是不可容忍的,代码性能测试时,你可能会发现很多各式各样的性能瓶颈点,大部分都意想不到,所以过早考虑性能而放弃编码安全是不可取的。就好比汇编效率最高,但也不会因此就直接用汇编编程,遇到真正的性能瓶颈时,有针对性的调优才是上策。
4.建议
在自己能力范围内,尽量优先考虑使用不可变对象的设计,如果真的引发了性能瓶颈,再有针对性地做出调整即可。