首先说结论,String 是线程安全的,不会造成多线程读写冲突问题。
原因如下:String 不是基础类型,而是对象,且是个不可变对象。我们无法改变一个 String 对象的内容,而只能用另一个 String 的对象来替换原有的引用。atomic 包是一个工具包,并不能替代原来的基础类型和对象的使用,因此用 atomic 去封装 String 没有任何用处。
顺带提一句,Atomic 原子类用的不是传统的锁机制,它是用的无锁化的 CAS 机制,通过 CAS 机制保证多线程修改个数值的安全性,CAS的全称是:Compare and Set,也就是先比较再设置的意思。
1.String 的不可变特性
String 是被 final 修饰的,无论调用 String 的什么方法,均无法修改 this 对象的状态。当确实需要修改 String 的值时,String 方法的实现是构造一个新的 String 返回。与可以直接改变原值的基础类型大不相同。
1 | public static void main(String[] args) { |
其实不光是 String 对象,Java 中的很多对象都符合上述不可变状态的特性。简而言之,当一个对象构造完成后,其状态就不再变化,我们称这样的对象为不可变对象(Immutable Object),这些对象关联的类为不可变类(Immutable Class)。
比如 Java 中的 Integer、Double、Long 等所有原生类型的包装器类型,也都是不可变的。那么明明可以直接修改 this 对象,为何还要大费周章地构造一个全新的对象返回呢?
2.不可变对象的优点
2.1 对并发友好
提到多线程并发,最让人苦恼的莫过于线程间共享资源的访问冲突。多线程错误不能像编译错误一样被自动发现,Java 不负责检测,因此不容易解决,对最有经验的程序员而言也需瞻前顾后,反复思量。目前大多数语言中,面对多线程冲突问题,都是采用序列化访问共享资源的方案,比如 Java 中的 synchronized 关键字和 Lock 锁对象等机制。此类方案最大的弊端在于:能不能保证多线程间没有冲突,完全取决于程序员对共享资源加锁和解锁的时机对不对。
然而,解决多线程冲突问题还有另一个方向,就是从多线程冲突的原因——共享资源上入手。
如果完全没有共享资源,多线程问题就天然不存在了,比如 Java 中的 ThreadLocal 机制就是利用了这一理念。但是大多数时候,线程间是需要使用共享资源互通信息的。此时,如果该共享资源诞生之后就完全不再变更(犹如一个变量),多线程间并发读取该共享资源是不会冲突的,所有线程无论何时读取,总能获取到一致、完整的资源状态。不可变对象就是这样一种诞生之后就完全不再变更的对象,该类对象天生支持多线程间共享。
某个线程想要修改共享资源 A 的状态时,不去直接修改 A 本身的状态,而是先在本线程中构造一个新状态的共享资源 B,待 B 构造完整后,再用 B 去直接替换 A,由于对引用赋值这个操作是原子性的,所以不会造成线程冲突问题。
但是需要注意可见性问题,如果想要 B 替换 A 之后,其他所有线程实时感知到此变化,需要使用 volatile 关键字保证可见性。
1 | public class Test { |
值得注意的是,线程安全需同时考虑原子性和可见性问题,所以网上常说的不可变对象是线程安全的,其实是不严谨的。
其实,所有的函数式编程语言 Lisp、Erlang 等,都从语法层面保证你只能使用不可变对象,所以所有函数编程语言是天生对并发友好的,这也是在一些高并发场景中,函数式编程语言更受青睐的原因。
2.2 易于在进程内缓存
当一个对象被频繁访问,而生成该对象的开销较大时,经常需要进行进程内缓存,即将频繁访问的对象存入一个缓存集合中(比如 Map),当需要使用该对象时,优先从缓存中提取。
使用进程内缓存就不得不面对缓存污染问题,当缓存的对象被提取使用时,如果上层业务代码修改了该缓存对象的状态,那么当再次从缓存中提取该对象时,该对象的状态已经不再是最开始加入缓存时的状态了,即已经被污染了。缓存污染会导致很多问题,比如业务数据被意外篡改、业务数据间的互相干扰等。
通常为了保证缓存不被污染,当我们从缓存中提取对象时,会返回原始缓存对象的一个深拷贝,这样无论上层业务代码对提取到的对象如何修改,均不会对缓存本身造成影响。
但是深拷贝毕竟有额外的性能开销,此时如果缓存的是不可变对象,就皆大欢喜了。因为你可以放心大胆的把缓存对象的引用返回给上层代码使用,因为无论上层代码怎样操作,它也无法修改一个不可变对象的状态,这也就天然规避了缓存污染问题,同时也可将深拷贝带来的性能开销延迟到真正需要修改对象时才发生。
2.3 更好的可维护性
当我们在代码中看到一个不可变对象时,心情是轻松的,因为这类对象很单纯,不会在哪个隐藏的逻辑分支中偷偷改变自身的状态,对代码的测试、调试和阅读理解都有好处。
3.不可变对象的局限
3.1 编程思维的转变
如果所有对象都被设计为不可变的,等价于使用函数式编程思维,编程思维上的变化并非所有程序员都能很好的适应,如果适应不了,强行推广只会适得其反。况且 Java 本身也并不是纯粹的函数式编程语言。
3.2 性能上的额外开销
由于不可变对象需要复制一份状态用于修改后返回新的对象,如果设计和使用不当的话,可能因此形成性能瓶颈点。有人可能就想到了用 StringBuffer(线程安全)或 StringBuilder(线程不安全)来解决这个问题,这两个类都是对对象本身直接进行操作,不会生成新的对象。
但是不必过于担心性能问题,一方面内存拷贝速度极快,另外也并非所有额外的性能开销都是不可容忍的,代码性能测试时,你可能会发现很多各式各样的性能瓶颈点,大部分可能都是你意想不到的,所以过早考虑性能而放弃编码安全是不可取的。就好比汇编效率最高,但是也不会因此所有代码都直接汇编编程,遇到真正的性能瓶颈时,有针对性的做汇编层面的调优才是上策。
4.建议
在自己能力范围内,尽量优先考虑使用不可变对象的设计。性能问题可以不必过于担心,如果引发了性能瓶颈,再有针对性地做出调整。
参考文章:Java进阶知识点4:不可变对象与并发 - 从String说起
(完)