并发安全
什么是线程安全性
在《Java并发编程实战》中,定义如下:
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
如何做到线程安全
线程封闭
实现好的并发是一件困难的事情,所以很多时候我们都想躲避并发。避免并发最简单的方法就是线程封闭。什么是线程封闭呢?
就是把对象封装到一个线程里,只有这一个线程能看到此对象。那么这个对象就算不是线程安全的也不会出现任何安全问题。实现线程封闭有哪些方法呢?
- ad-hoc线程封闭
这是完全靠实现者控制的线程封闭,他的线程封闭完全靠实现者实现。Ad-hoc线程封闭非常脆弱,应该尽量避免使用。 - 栈封闭
栈封闭是我们编程当中遇到的最多的线程封闭。简单的说就是使用局部变量。多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到线程栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。 - TheadLocal
ThreadLocal是实现线程封闭的最好方法。ThreadLocal内部维护了一个Map,Map的key是每个线程的引用,而Map的值就是我们要封闭的对象。每个线程中的对象都对应着Map中一个值,也就是ThreadLocal利用Map实现了对象的线程封闭。
点击这里回顾ThreadLocal。
使用无状态类
没有任何成员变量的类,就叫无状态的类,这种类一定是线程安全的。
public class NoStateClass {
public int service(int a,int b){
return a+b;
}
//虽然这个传入的引用可能不安全,但这个方法所在的类并没有问题,还是线程安全的
public void serviceUser(UserVo user){
//do sth
}
}
public class UserVo {
private int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
让类不可变
让状态不可变,两种方式:
- 加final关键字,对于一个类,所有的成员变量应该是私有的,同样的只要有可能,所有的成员变量应该加上final关键字,但是加上final,要注意如果成员变量又是一个对象时,这个对象所对应的类也要是不可变,才能保证整个类是不可变的。
public class ImmutableClass {
private final int a;
//不安全,除非UserVo已实现不可变。
//final只是保证了user这个引用不可变。
private final UserVo user = new UserVo();
public int getA() {
return a;
}
public UserVo getUser() {
return user;
}
public ImmutableClass(int a) {
this.a = a;
}
}
- 根本就不提供任何可供修改成员变量的地方,同时成员变量也不作为方法的返回值。
import java.util.ArrayList;
import java.util.List;
public class ImmutableClassToo {
private final List<Integer> list = new ArrayList<>(3);
public ImmutableClassToo() {
list.add(1);
list.add(2);
list.add(3);
}
public boolean contains(int i){
return list.contains(i);
}
}
安全地发布
类中持有的成员变量,如果是基本类型,发布出去(外界通过get()得到),并没有关系,因为发布出去的其实是这个变量的一个副本。
示例:
public class SafePublish {
private int i;
public SafePublish() {
i = 2;
}
public int getI() {
return i;
}
public static void main(String[] args) {
SafePublish safePublish = new SafePublish();
int j = safePublish.getI();
System.out.println("before j="+j);
j = 3;
System.out.println("after j="+j);
System.out.println("getI = "+safePublish.getI());
}
}
结果:
before j=2
after j=3
getI = 2
但是如果类中持有的成员变量是对象的引用,则不行(外界得到的引用也指向同一个对象)。
示例:
import java.util.ArrayList;
import java.util.List;
public class UnSafePublish {
private List<Integer> list = new ArrayList<>(3);
public UnSafePublish() {
list.add(1);
list.add(2);
list.add(3);
}
public List getList() {
return list;
}
public static void main(String[] args) {
UnSafePublish unSafePublish = new UnSafePublish();
List<Integer> list = unSafePublish.getList();
System.out.println(list);
list.add(4);
System.out.println(list);
System.out.println(unSafePublish.getList());
}
}
结果:
[1, 2, 3]
[1, 2, 3, 4]
[1, 2, 3, 4]
解决办法:
在发布对象出去的时候,用线程安全的方式包装进行包装。
比如将上面的例子进行修改:
private static List<Integer> list
= Collections.synchronizedList(new ArrayList<>(3));
注意:
这样并不能保证如下操作线程安全:
list.set(0,1 + list.get(0));
set是原子的,get是原子的,但是1 + list.get(0)不是原子的。使用的时候仍要小心。
还有更具一般性的例子如下:
public class SoftPublicUser {
private final UserVo user;
public UserVo getUser() {
return user;
}
//实际上拿到的是SynUser
public SoftPublicUser(UserVo user) {
this.user = new SynUser(user);
}
private static class SynUser extends UserVo{
private final UserVo userVo;
private final Object lock = new Object();
public SynUser(UserVo userVo) {
this.userVo = userVo;
}
@Override
public int getAge() {
synchronized (lock){
return userVo.getAge();
}
}
@Override
public void setAge(int age) {
synchronized (lock){
userVo.setAge(age);
}
}
}
}
volatile
并不能保证类的线程安全性,只能保证类的可见性,最适合一个线程写,多个线程读的情景。
加锁和CAS
我们最常使用的保证线程安全的手段,使用synchronized关键字,使用显式锁,使用各种原子变量,修改数据时使用CAS机制等等。
安全问题
死锁
概念
是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
死锁发生的必要条件
- 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
- 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
- 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
- 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生:
- 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
- 打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
- 打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
- 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。
避免死锁常见的算法有有序资源分配法、银行家算法。
数据库中的死锁
数据库里多事务而且要同时操作多个表的情况下。所以数据库设计的时候就考虑到了检测死锁和从死锁中恢复的机制。比如oracle提供了检测和处理死锁的语句,而mysql也提供了“循环依赖检测的机制”。
Java中的死锁
简单顺序死锁
往往从代码一眼就看出获取锁的顺序不对。
点击这里查看示例,还可以直到定位死锁的方法。
动态顺序死锁
顾名思义也是和获取锁的顺序有关,但是比较隐蔽。
点击这里查看示例,还可以知道什么是活锁。
危害
- 线程不工作了,但是整个程序还是活着的
- 没有任何的异常信息可以供我们检查。
- 一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对生产平台的程序来说,这是个很严重的问题。
活锁
两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。
解决办法:每个线程休眠随机数,错开拿锁的时间。
线程饥饿
低优先级的线程,总是拿不到执行时间
并发下的性能
使用并发的目标是为了提高性能,引入多线程后,其实会引入额外的开销,如线程之间的协调、增加的上下文切换,线程的创建和销毁,线程的调度等等。过度的使用和不恰当的使用,会导致多线程程序甚至比单线程还要低。
衡量应用的程序的性能:服务时间,延迟时间(多快),吞吐量(处理能力的指标,完成工作的多少),可伸缩性等等。速度和量往往是相互矛盾的,提升一方就会牺牲一方。
对服务器应用来说:多少(可伸缩性,吞吐量)这个方面比多快更受重视。
线程引入的开销
上下文切换
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。
上下文切换是计算密集型操作。也就是说,它需要相当可观的处理器时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于50~10000个时钟周期,也就是几微秒。
UNIX系统的 vmstat命令能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由IO或竞争锁导致的阻塞引起的。
内存同步
同步操作的性能开销包括多个方面。在 synchronized和 volatile提供的可见性保证中可能会使用一些特殊指令,即内存屏障( Memory Barrier)。
内存屏障可以刷新缓存,使缓存无效刷新硬件的写缓冲,以及停止执行管道。
内存屏障可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。
更多关于内存屏障,可以看这里。
阻塞
引起阻塞的原因:包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待等等。
阻塞会导致线程挂起【挂起:挂起进程在操作系统中可以定义为暂时被淘汰出内存的进程,机器的资源是有限的,在资源不足的情况下,操作系统对在内存中的程序进行合理的安排,其中有的进程被暂时调离出内存,当条件允许的时候,会被操作系统再次调回内存,重新进入等待被执行的状态即就绪态,系统在超过一定的时间没有任何动作】。
很明显这个操作至少包括两次额外的上下文切换,还有相关的操作系统级的操作等等。
如何减少锁的竞争
减少锁的粒度
使用锁的时候,锁所保护的对象是多个,当这些多个对象其实是独立变化的时候,不如用多个锁来一一保护这些对象。但是如果有同时要持有多个锁的业务方法,要注意避免发生死锁
缩小锁的范围
对锁的持有实现快进快出,尽量缩短持由锁的的时间。将一些与锁无关的代码移出锁的范围,特别是一些耗时,可能阻塞的操作
避免多余的锁
两次加锁之间的语句非常简单,导致加锁的时间比执行这些语句还长,这个时候应该进行锁粗化—扩大锁的范围。
锁分段
ConcurrrentHashMap就是典型的锁分段。
替换独占锁
在业务允许的情况下:
- 使用读写锁,
- 用自旋CAS
- 使用系统的并发容器
线程安全的单例模式
懒汉式-双重检查模式
public class SingleDcl {
//1.私有构造函数
private SingleDcl(){ }
//2.创建本类引用
private static SingleDcl singleDcl;
// private volatile static SingleDcl singleDcl;
//3.提供访问方法
public static SingleDcl getInstance(){
//第一次检查,不加锁,避免每次操作都要加锁
if (singleDcl == null){
//加锁
synchronized(SingleDcl.class){
//第二次检查
if (singleDcl == null){
//1.内存中分配空间
//2.在空间中初始化对象
//3.引用指向空间地址(一旦这一步完成,singleDcl != null)
singleDcl = new SingleDcl();//可分解为3步
}
}
}
return singleDcl;
}
}
以下情况会出现问题:
2条线程同时getInstance(),第一条执行到 singleDcl = new SingleDcl(); 时,由于重排(编译器为了优化代码,编译好的字节码定义的程序顺序可能和原来不一样),执行完1后,先执行了3,此时没有执行2,而第二条线程恰好执行到了第一次检查,发现 singleDcl == null 不成立直接返回 singleDcl ,而实际上第一条线程还没有创建完对象,会抛出空指针异常。
解决方案:
创建本类引用时加volatile关键字,可以防止指令重排序。
这个方法基本不用,因为有更好的方法,如下。
懒汉式-延迟初始化占位类模式
需要用到静态内部类的时候,静态内部类才会加载。
JVM里某个类只能被加载一次,如果有其它线程尝试加载会被加锁。
public class SingleInit {
//1.私有构造函数
private SingleInit(){}
//2.创建延迟初始化占位类
private static class InstanceHolder{
private static SingleInit instance = new SingleInit();
}
//3.提供访问方法
public static SingleInit getInstance(){
return InstanceHolder.instance;
}
}
枚举
public enum Singleton{
INSTANCE;
}
相比上面两种,它不是懒加载的,但可以防止反射构建多实例。
饿汉式
在声明的时候就new这个类的实例,因为在JVM中,对类的加载和类初始化,由虚拟机保证线程安全。
第一次使用类的时候,会进行加载,验证,准备,解析,初始化五个阶段。在准备阶段,会为静态变量分配空间,在初始化阶段,会为静态变量赋值。
public class Singleton {
//1.私有构造函数
private Singleton(){}
//2.创建本类对象
private static Singleton s = new Singleton();
//3.提供访问方法
public static Singleton getInstance() {
return s;
}
}
上面所有的创建的本类对象的引用都用 static 修饰,主要是因为静态方法只能调用静态的成员变量。
【并发编程】目录:
【并发编程】之走进Java里的线程世界
【并发编程】之学会使用线程的并发工具类
【并发编程】之学会使用原子操作CAS
【并发编程】之深入理解显式锁和AQS
【并发编程】之一文彻底搞懂并发容器
【并发编程】之Java面试经常会问到的线程池,你搞清楚了吗?
【并发编程】之Java并发安全知识点总结
【并发编程】之大厂很可能会问到的JMM底层实现原理