线程安全含义
Java 中多线程编程极大地提高了效率,但是也会带来一些隐患,这就是 Java 中的线程安全问题。
在单线程中不会出现线程安全问题,而在多线程编程中,有可能出现同时访问同一个资源的情况,这种资源可以是:一个变量、一个对象、一个文件、一个数据表等,而多个线程同时访问同一资源的时候,就会存在一个问题。
线程是 CPU 调度的最小单元,通俗来讲就是,线程的代码是按照顺序来执行的,执行完毕就是结束的一条线。
由于每个线程执行的过程是不可控的,所以很可能导致最终的结果和预期相违背或者直接导致程序出错。
举个栗子:
现在有两个线程往数据表里插入数据,要求不能插入重复的数据。
某一时刻,两个线程同时读到了数据 X,之后
- 两个线程同时查询数据表有没有数据 X,如果同时查到数据 X 不存在;
- 两个线程同时将数据 X 插入数据表。
多个线程同时访问一个资源时,会产生程序运行结果和预期不一致的情况
,这就是线程安全问题。
被多个线程共享的资源,称为: 临界资源,也叫 共享资源。
tips: 当多个线程执行一个方法,方法内部的局部变量不是临界资源,因为方法是在栈上执行的,而 Java 栈是线程私有的,因此不会产生线程安全问题。
基本上所有的并发模型在解决线程安全问题时,都采用 序列化访问临界资源 的方案,即在同一时刻,只能有一个线程访问临界资源,也成同步互斥访问。
通常来说,是在访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其它线程继续访问。
Java 内存模型 JMM
什么是 JMM
JMM(Java Memory Model)是一种基于 计算机内存模型(定义了共享内存系统中多线程程序读写操作行为的规范) 的,屏蔽了各种硬件和操作系统访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制和规范。
多线程执行中,主内存的变量为共享变量,只有一份,多个线程操作主内存中的共享变量时,都要先从主内存中拷贝一份副本到线程工作内存,执行完毕后,都要将变量副本值同步回主内存中。
tips: 线程工作内存是线程私有内存,线程间无法访问对方的工作内存。
线程对一个变量赋值的流程
线程工作内存和主内存之间的数据同步就是通过 JMM,它规定了何时以及如何做线程工作内存和主内存之间的数据同步。
JMM 保证了共享内存的:
- 原子性,对共享内存的操作必须是要么全部执行直到执行结束,且中间过程不能被任何外部因素打断,要么就不执行。
- 可见性,多线程操作共享内存时,执行结果能够及时的同步到共享内存,确保其他线程对此结果及时可见。
- 有序性,程序的执行顺序按照代码顺序执行,在单线程环境下,程序的执行都是有序的,但是在多线程环境下,JMM 为了性能优化,编译器和处理器会对指令进行重排,程序的执行会变成无序。
线程同步
Java 提供了一系列的关键字和类来保证线程安全,主要有以下几种:
- synchronized 关键字
- volatile 关键字
- java.util.concurrent.atomic 包下一系列原子类
- Lock 锁
synchronized
1> 保证方法或代码块操作的原子性
synchronized 保证方法内部或代码块内部资源(数据)的互斥访问。即同一时间,由同一个 Monitor(监视锁) 监视的代码,最多只能有一个线程在访问。
被 synchronized 描述的方法或者代码块在多线程环境下同一时间只能由同一个线程访问,在持有当前 Monitor 的线程执行完成之前,其它线程想要调用就必须排队,直到上一个持有当前 Monitor 的线程执行结束,释放 Monitor,下一个线程才能获得 Monitor 执行。
tips: 如果存在多个 Monitor 的情况下,多个 Monitor 之间是不互斥的。多个 Monitor 的情况出现在自定义多个锁分别来描述不同的方法或代码块,synchronized 在描述代码块时可以指定自定义 Monitor ,默认为 this 即当前类。
2> 保证监视资源的可见性
保证多线程环境下对监视资源的数据同步。任何线程在获取到 Monitor 的第一时间,会将共享内存中的数据复制到线程工作内存中,任何线程释放 Monitor 的第一时间,会将线程工作内存中的数据复制到共享内存中。
3> 保证线程间操作的有序性
synchronized 的原子性保证了由其描述的方法或代码块的操作具有有序性,同一时间最多只能由一个线程访问,不会触发 JMM 指令重排机制。
对于 synchronized 方法或代码块,当出现异常时,JMM 会自动释放当前线程占用的锁,因此不会由于异常导致出现死锁现象。
在 Java 中,每一个对象都有一个锁标记(Monitor)。
- 当一个线程正在访问一个对象的 synchronized 方法时,
其它线程不能访问该对象的其它 synchronized 方法
。因为一个对象只有一把锁,当一个线程获取了该对象的锁之后,其它线程就无法获取该对象的锁了,所以无法访问该对象的其它 synchronized 方法。 - 当一个线程正在访问一个对象的 synchronized 方法时,
其它线程可以访问该对象的非 synchronized 方法
。这个原因是,访问非 synchronized 方法不需要获取该对象的锁,如果一个方法没被 synchronized 修饰,说明该方法不会访问临界资源,那其它线程随便访问该方法。 - 如果某一时刻,线程 A 需要访问对象 obj_1 的 synchronized 方法 fun_1,线程 B 需要访问对象 obj_2 的 synchronized 方法 fun_1,即使 obj_1、obj_2 都是某个类的对象,也不会产生线程安全问题,因为访问的是不同的对象,不存在互斥问题。
1 | public class ThreadSYNTest_1 { |
synchronized 代码块比方法更灵活,因为可能一个方法中只有一部分代码需要同步,如果此时对这个方法用 synchronized 修饰,就会影响程序的效率。
在 Java 中,每一个类也有一个锁标记(Monitor),用来控制对 static 数据成员的并发访问。
- 某一时刻,一个线程执行一个对象的非 static synchronized 方法,另一个线程执行这个对象所属类的 static synchronized 方法,这样不会发生互斥现象,因为访问 static synchronized 方法占用的是类锁,而访问非 static synchronized 方法占用的是对象锁,类锁和对象锁不互斥。
1 | public class ThreadSYNTest_2 { |
volatile
保证被 volatile 关键字描述的变量的操作具有 可见性 和 有序性 (禁止指令重排)。
注意:
- volatile 只对基本类型(byte、char、short、int、long、float、double、boolean)的赋值操作和对象的引用赋值操作有效。
- 对于 i++ 此类复合操作,volatile 无法保证其有序性和原子性。
- 相对 synchronized 来说,volatile 更加轻量一些。
java.util.concurrent.atomic
java.util.concurrent.atomic 包提供了一系列的 AtomicBoolean、AtomicInteger、AtomicLong 等类,使用这些类来声明变量可以保证对其操作具有 原子性 来保证线程安全。
实现原理和 synchronized 使用 Monitor(监视锁) 来保证资源在多线程下 阻塞互斥 访问不同,java.util.concurrent.atomic 包下的各原子类是基于 CAS(ComparAndSwap) 操作原理实现的。
CAS 又称无锁操作,是一种乐观锁策略,原理就是多线程环境下各线程访问共享变量不会加锁阻塞排队,线程不会被挂起。通俗来讲就是一直循环对比,如果访问冲突就重试,知道没有冲突为止。
Lock
Lock 也在 java.util.concurrent 包下,是一个接口,其中定义一系列锁的操作方法,Lock 接口主要有 ReentrantLock、ReentrantReadWriteLock.ReadLock、ReentrantReadWriteLock.WriteLock 实现类。
与 synchronized 不同是 Lock 提供了获取锁和释放锁等相关接口,使得使用上更加灵活,同时也可以做更加复杂的操作。
已经有了 sychronized,为什么还会出现 Lock 呢?
线程执行 synchronized 修饰的方法或代码块会获取锁,获取锁的线程释放锁会出现两种情况:
- 获取锁的线程执行完了代码,线程正常释放锁;
- 获取锁的线程执行出现异常,JMM 让线程自动释放锁。
如果获取锁的线程没有出现异常,但是要等待 IO 或者其它原因(比如调用了 sleep 方法)被阻塞了,但是有没有释放锁,其它线程就只能一直等待,极大的影响效率。因此就需要一种机制避免等待的线程一直等待下去,通过 Lock 就可以实现。
再举个栗子:当多个线程读写文件时,读写、写写会发生冲突,但是读读是不会发生冲突的,但是采用 synchronized 来实现同步的话,多个线程如果只是同步读的情况,一部分线程也需要等待。因此需要一种机制,保证多个线程同时读的情况不需要等待,通过 Lock 也可以实现。
此外,Lock 可以检测线程有没有获取到锁,synchronize 是无法做到的。
总结来说,Lock 相比 synchronized 更加灵活,但是需要注意几点:
sychronized 是 Java 的关键字,是内置特性,Lock 不是 Java 内置的,是一个类,通过这个类实现同步访问。
Lock 需要手动释放锁,synchronized 的方法或代码块执行完之后,系统会自动让线程释放锁,而 Lock 必须用户手动释放,如果没有主动释放,就可能出现死锁。
总结
1> 出现线程安全的问题的原因
多线程并发环境下,多个线程共同访问同一共享内存资源时,其中一个线程对资源进行写操作的中间(写入已经开始,但还没结束),其它线程对这个写了一半的资源进行了读操作,或者对这个写了一半的资源进行了写操作,导致此资源出现数据错误。
2> 如何避免出现线程安全问题?
- 保证共享资源在同一时间只能由一个线程进行操作(原子性、有序性)。
- 线程操作的结果及时刷新,保证其它线程可以立即获取到修改后的最新数据(可见性)。