Java线程安全
《深入了解Java虚拟机》读书笔记
阿姆达尔定律(Amdahl law)
Amdahl加速定律定义了一个系统进行并行化改造时候可以提升的性能占比
公式大约如下:
$$
S’=\cfrac{1}{1-f+\cfrac{f}{S}}=\cfrac{1}{1-(1-\cfrac{1}{S})f}
$$
其中,
S’=是整体加速比
f=加速的部分占到整体系统的比重
S=加速了的部分的加速比重
举例:假设某个程序中,你优化了80%的代码,对这80%的代码你获得了加速比10,那么对整个程序而言,你的优化获得的加速比为:1/(1–0.8+0.8/10)=3.57,这远小于10。
S无限增大时候,S’逼近
$$
\cfrac{1}{1-f}
$$
也就是说,优化程序80%的代码,最大获得的加速比为5。
线程安全的定义
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的
——Brian Goetz《Java Concurrency In Practice》
Java中各种操作共享数据分类
不可变
Immutable,JDK1.5内存模型被修正以后的Java语言
Java语言中,如果共享数据是一个基本数据类型,只要在定义时使用final关键字修饰,可以保证是不可变的。
如果是一个对象,那就需要保证对象的行为不会对状态产生任何影响,比如java.lang.String中,substring()、replace()、concat()等方法,都不会影响它原来的值
保证对象行为不受影响的途径,最简单的就是把对象中带有状态的变量都声明为final,比如Integer类
Java API中,不可变的类型包括
- String
- 枚举类
- java.lang.Number的部分子类
- 不含AtomicInteger,AtomicLong,这两个类使用unsafe 的CAS操作 进行实现
绝对线程安全
完全满足Brian Goetz 提出的要求,调用者也不需要额外的同步措施,Java中基本没有类似的实现。
即使是在全部方法中使用了 synchronized
关键字修饰的 Vector类,在多线程的场景中调用方法,也还是会出现线程问题,虽然每个操作都是原子的,都会线程安全,但是多个原子操作的顺序却可能导致线程问题
相对线程安全
通常意义上的线程安全,保证一个对象单独的操作是线程安全的即可。Java中,大部分线程安全类都是属于这种类型,比如Vector、HashTable、Collections的synchronizedCollection()等
线程兼容
指对象本身不是线程安全的,需要调用者进行正确的同步手段来保证对象在并发环境中可以安全使用。与之前Vector和HashTable对应的ArrayList、HashMap等就是。
Java中绝大多数的API属于这类型
线程对立
指无论调用端是否采取了同步措施,都无法在多线程的环境中并发使用的代码。
Java中此类例子很少,常见的对立的例子如下:
- Thread类的 suspend()方法以及resume()方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时候是否同步,目标线程都有死锁的风险。如果subpend()中断的线程就是即将要执行resume的那个线程,那就肯定要死锁了。由于以上原因,suspend()、resume()方法已经被JDK声明废弃了
- System.sctIn()
- System.setOut()
- System.runFinalizersOnExit()
线程安全实现方式
互斥同步
悲观锁的思路体现、也称阻塞同步
常见的并发正确性方案,同步指的是多个线程并发访问共享数据的时候,保证共享数据在同一时刻只被一个或者一些(信号量时候)线程使用。
互斥是实现同步的一种手段,主要的实现方式如下,互斥是因,同步是果,互斥是方法、同步是目的
Java中,synchronize关键字是最基本的互斥同步手段。synchronized关键字经过编译以后,会在同步块的前后分别形成monitorenter和 monitorexit两个字节码指令。
字节码需要一个reference类型的参数来指明要锁定和解锁的对象
- 如果java程序中的synchronized明确指定了对象参数,那就是这个对象的reference
- 如果没有指定
- synchronized修饰的是实例方法,取对应的对象实例
- 修饰静态类方法,取Class对象来作为锁对象
monitorenter 进行锁对象计数器+1,monitorexit进行锁对象计数器-1。
synchronized同步块对于同一线程来说是可重入的,不会出现自己把自己锁死的问题。
阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转化为和心态,因此状态转换会耗费很多的处理器时间。对于简单的同步块,比如getter方法,状态转换可能比用户代码执行的时间还要长。
synchronized在java中是一个重量级操作,非必要情况下不要使用。当然JVM也会做一些优化
使用ReentryLock实现锁
reentryLock与synchronized很相似,具备一样的线程重入特性,不过增加了一些高级功能
- 等待可中断
- 当等待锁的线程等待时间太长,可以中断等待,改为处理其他事情
- 公平锁
- 多个线程等待同一个锁时候,必须按照申请的时间顺序来依次获得锁,synchronized是非公平的
- reentryLock可以通过构造参数选择是否公平
- 锁绑定多个条件
- 一个ReentryLock可以同时绑定多个Condition对象,synchronized中,wait\notify\notifyAll方法可以实现一个隐含的条件
- newCondition可以实现多个条件
- JDK1.6以上,ReentryLock与synchronized的性能完全持平,没有上述场景的前提下,建议使用原生的synchronized实现功能
非阻塞同步
随着硬件指令集的发展,基于冲突检测的乐观并发策略称为 非阻塞同步,区别于互斥同步,是一种先进行操作,发生冲突以后补偿的乐观锁思路。
这种策略需要操作和冲突检测这两个步骤具有原子性(当然不是使用synchronized。。。),常用指令有:
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,CAS)
- 加载连接、条件存储(Load Linked/Store Conditional,LL/SC)
前3条是老的指令,后2条是现代处理器新增加的
CAS
三个操作数,分别是内存位置V、旧的预期值A、新值B
CAS操作的时候,当且仅当V符合旧的预期值A时候,处理器会用新值B更新V值,否则不执行
JDK1.5以后,可以使用sun.misc.Unsafe类中的对应方法实行,并且限制了只有启动类加载器加载的class才能访问它,因此,如果不采用反射手段,只能通过其他Java的API来间接使用
AtomicLong等就是这样实现的
无同步方案
没有或者不涉及共享数据的前提下,可以采用这种方案。简单介绍2类
锁优化
自旋锁与自适应自旋
指的是让等待锁的线程通过空循环(自旋),而不进行让出CPU的操作,避免操作系统进行状态切换带来的时间损失。
- 物理机器需要有一个以上的处理器,能让两个或以上的线程同时并行执行
- 自旋等待的时间必须有一定的限度
- -XX:+UseSpinning 开启自旋,1.6以后默认开启
- -XX:PreBlockSpin 可以修改自旋次数
- 1.6以后引入的自适应的自旋锁,通过程序运行和性能监控的不断完善,虚拟机对锁进行状况预测
锁消除
如果一段代码中,堆上所有的数据都不会逃逸出去从而被其他线程访问,那就可以认为数据是线程私有的,相当于栈上数据
锁粗化
有一系列的代码需要加锁,那么虚拟机会把加锁同步的范围扩展,粗化到整个操作序列的外部
轻量级锁
JDK1.6引入,在对象头中指定了一个标记位,加锁时候,通过CAS操作将线程栈与对象联系起来
- 使用CAS操作替代互斥量操作,如果一个对象,只有一个线程要获取锁,那么使用这种情况
- “对于绝大多数的锁,在整个同步周期内都是不存在竞争的”
- 如果存在锁竞争,那么除了互斥量的开销以外,还额外发生了CAS操作
偏向锁
JDK1.6引入,同轻量级锁比较,偏向所就是直接将CAS操作也一并省略掉的技术
- 一个线程在获取对象的锁同时,对象会在标记位中记录下这个线程的id
- 后续的执行过程中,如果锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步
- -XX:+UseBiasedLocking JDK1.6默认开启
- 有的时候禁止偏向锁可以提高性能