1.线程安全
如果一个对象能安全地被多个线程同时使用,那么它就是线程安全的。
当多个线程访问同一个对象时,如果不需要考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都能得到正确的结果,那这个对象就是是线程安全的。
2.Java语言中的线程安全
2.1不可变
JDK1.5后,不可变(Immutable)对象一定是线程安全的,注意final修饰的基本数据类型是不可变的,但是引用类型只能保证一级不可变,即当前引用不可再赋值,但引用的对象的非final属性是可以修改的。
2.2绝对线程安全
无论怎么使用,都是线程安全的,满足上面第二个定义。
2.3相对线程安全
比如Vector,虽然它的方法加了synchronized关键字,但并不能说它就是绝对安全的。
2.4线程兼容
对象本身不是线程安全的,但是可以通过一定的同步来实现线程安全,例如synchronized。
2.5线程对立
如 Thread.suspend()和Thread.resume()
3.线程安全的实现方法
同步是指在多线程并发访问共享数据时,保证同一时刻只被一个(或者是一些,使用信号量时)线程使用。
3.1互斥同步
互斥是实现同步的手段,临界区、互斥量、信号量都是主要的互斥实现方式。Java中最基本的互斥手段就是synchronized关键字,synchronized关键字在编译后,会在同步块前后分别形成monitorenter和monitorexit指令。这两个指令需要一个reference类型的参数来指明要锁定和解锁的对象。如果synchronized明确指定了对象参数,那就是这个对象的reference,如果没有指定,那就要区分是实例方法还是类方法,取当前对象或者这个类对应的Class对象。
3.2非阻塞同步
互斥同步的主要问题是进行线程的阻塞和唤醒所带来的性能问题,因为这种方式会阻塞其他线程,因此也可以称为阻塞同步。从处理方式上来看,属于悲观的并发策略,即不管有没有竞争,都进行同步。
随着硬件指令系统的发展(需要一些原子操作指令的支持,例如CAS,早期的计算机指令系统可能没有这样的指令),有了另一个选择,基于冲突检测的乐观并发策略,即先进形操作,如果没有其他线程争用共享数据,那就操作成功了,如果存在竞争,再采取必要的补救措施,比如不断地重试,直到成功为止。
4.锁优化
4.1自旋锁与自适应自旋
由于挂起线程和唤醒线程需要切换的内核状态进行,这是个不小的开销。虚拟机的开发团队研究发现许多应用,共享锁的锁定转态通常是持续很短的时间,所以可以让在等待获锁的线程不进入阻塞状态,而是继续执行自旋操作,稍等一下就能获得锁,这样做在一定程度上避免用户态与内核态的切换,但自旋的线程会继续占用CPU时间片。
自旋时间的选择也是很关键的,自旋多久后仍然没有获得锁就进入阻塞状态?所以在JDK1.6后,引入了自适应的自旋锁,由前一次在同一个锁上的自旋时间和锁的拥有线程的状态决定。
4.2锁消除
虚拟机即时编译器在运行时,对一些代码上要求同步,但被检测到不可能存在数据竞争的锁进行清除。这种通常不是开发人员自己写出来的,举一个例子:
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
由于String是一个不可变的类,在字符串连接时都是创建一个新的String对象来完成的,因此,javac编译器对String对象连接做了自动化。在JDK1.5以前,是通过StringBuffer来完成,而JDK1.5及之后,是通过StringBuilder来完成。那JDK1.5之前上面的代码就会变成:
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
而StringBuffer的append()方法是加了synchronized关键字的同步方法,但是很显然这种情况下的同步是完全没有必要的,所以虚拟机将这种锁清除掉以提高性能。
4.3锁粗化
同步有一个原则是,让同步块尽量小一下,一般情况下是正确的,但如果一系列的操作都对同一个对象进行加锁解锁,会带来不小的性能开销,这种情况还不如把同步范围扩大至一系列操作之前,这样只需要加/解锁一次就行了。
4.4轻量锁
前面《运行时数据区》一文中提到对象的内存布局包括3部分:
对象头(Header)
实例数据(Instance Data)
对齐填充(Padding)非必须
HotSpot虚拟机的对象头(Object Header)包含两部分的信息:
- 第一部分用户存储对象自身的运行时数据,如 HashCode、GC分代年龄(Generational GC Age)等,这部分数据在32bit和64bit的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”,它是轻量级锁和偏向锁的关键;
- 另外一部分用于存储方法区对象类型的引用,如果是数组对象的话,还会有一个额外的部分用于存储数组的长度。
32bit HotSpot虚拟机下的对象状态,为锁定状态下Mark Word 32bit中,25bit是HashCode,4bit是对象分代年龄,2bit是标记(例如未锁定是01),1bit固定为0。
存储内容 | 标记位 | 状态 |
---|---|---|
对象哈希码,分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID,偏向时间戳,对象分代年龄 | 01 | 可偏向 |
- 在代码进入同步代码块时,如果对象是未锁定状态,虚拟机会首先会在当前线程的栈帧中创建一个锁记录(Locked Record)空间,用于存储锁对象当前的Mark Word拷贝,叫Displaced Mark Word;
- 采用CAS操作将锁对象的Mark Word修改为指向锁记录的指针,如果更新成功,那线程就拥有了该锁对象,并且对象的Mark Word的标记位(最后2bit)修改为00。
轻量级锁提升性能的依据是:对于绝大部分的锁,同步过程是不存在竞争的。如果没有竞争,那轻量级的CAS操作避免了互斥量的开销,但如果存在竞争,那性能反而传统的重量级锁慢(CAS+互斥信号量)。
4.5偏量锁
如果说轻量级锁是在无竞争条件下,通过CAS操作去消除的同步使用互斥信号量,那偏向锁就是在无竞争条件下把整个同步都消除掉,连CAS也不用做。
“偏”指的是这个锁对象会偏向第一个获取它的线程,如果在接下来的的执行过程中,该锁没有被其他线程获取,则持有该偏向锁的线程将永远不再需要同步。
当锁对象第一次被获取时,标记为被设为“01”,即偏向模式,并且把获取到这个锁的线程ID记录在Mark Word中,后面持有偏向锁的线程在进入同步代码块,就不需要再同步了。