线程安全问题
三大特性
原子性、可见性、有序性
什么是原子性
原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,即不被中断操作,要不全部执行完成,要不都不执行。就好比转账,从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。2个操作必须全部完成。
那程序中原子性指的是最小的操作单元,比如自增操作,它本身其实并不是原子性操作,分了3步的,包括读取变量的原始值、进行加1操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可能才执行到第二部,另一个线程就已经读取了值,导致结果错误。那如果我们能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据
什么是可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。
什么是有序性
程序执行的顺序按照代码的先后顺序执行,在多线程编程时就得考虑这个问题。
线程安全的实例
当多个线程同时共享,同一个全局变量或静态变量(即局部变量不会),做写的操作时,可能会发生数据冲突问题,也就是线程安全问题。但是做读操作是不会发生数据冲突问题。
案例:抢票
public class ThreadSafeProblem {
public static void main(String[] args) {
Consumer abc = new Consumer();
// 注意要使用同一个abc变量作为thread的参数,
// 如果你使用了两个Consumer对象,那么就不会共享ticket了,就自然不会出现线程安全问题
new Thread(abc,"窗口1").start();
new Thread(abc,"窗口2").start();
}
}
class Consumer implements Runnable{
private int ticket = 100;
@Override
public void run() {
while (ticket > 0) {
System.out.println(Thread.currentThread().getName() + "售卖第" + (100-ticket+1) + "张票");
ticket--;
}
}
}
结果:
解决方法
使用多线程之间同步synchronized或使用锁(lock)。
为什么能解决?如果可能会发生数据冲突问题(线程不安全问题),只能让当前一个线程进行执行。代码执行完成后释放锁,然后才能让其他线程进行执行。这样的话就可以解决线程不安全问题。
有两种:
1.synchroized(自动锁,锁的创建和释放都是自动的)
2.lock jdk1.5并发包中的,是手动锁(手动指定锁的创建和释放),在我的博客并发包中有讲到
1. synchronized
1.1 同步代码块
synchronized(同一个锁){
//可能会发生线程冲突问题
}
将可能会发生线程安全问题的代码,给包括起来。也称同步代码块。synchronized 使用的锁可以是对象锁也可以是静态资源,如xxx.class,只有持有锁的线程才能执行同步代码块中的代码。没持有锁的线程即使获取CPU的执行权,也进不去。
锁的释放是在synchronized同步代码块执行完毕后自动释放
同步的前提:
1,必须要有两个或者两个以上的线程 ,如果小于2个线程,则没有用,且还会消耗性能(获取锁,释放锁)
2,必须是多个线程使用同一个锁
弊端:多个线程需要判断锁,较为消耗资源、抢锁的资源。
public class ThreadSafeProblem {
public static void main(String[] args) {
Consumer abc = new Consumer();
// 注意要使用同一个abc变量作为thread的参数,
// 如果你使用了两个Consumer对象,那么就不会共享ticket了,就自然不会出现线程安全问题
new Thread(abc,"窗口1").start();
new Thread(abc,"窗口2").start();
}
}
class Consumer implements Runnable{
private int ticket = 100;
@Override
public void run() {
while (ticket > 0) {
synchronized (Consumer.class) {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + "售卖第" + (100-ticket+1) + "张票");
ticket--;
}
}
}
}
}
1.2 同步函数
就是将synchronized加在方法上
分为两种:
第一种是非静态同步函数,即方法是非静态的,使用的this对象锁,如下代码所示
第二种是静态同步函数,即方法是用static修饰的,使用的锁是当前类的class文件(xxx.class)
public synchronized void sale () {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + "售卖第" + (100-ticket+1) + "张票");
ticket--;
}
}
1.3 多线程死锁线程
如下代码所示,
线程t1,运行后在同步代码块中需要oj对象锁,,运行到sale方法时需要this对象锁
线程t2,运行后需要调用sale方法,需要先获取this锁,再获取oj对象锁
那这样就会造成,两个线程相互等待对方释放锁。就造成了死锁情况。简单来说就是:
同步中嵌套同步,导致锁无法释放
class ThreadTrain3 implements Runnable {
private static int count = 100;
public boolean flag = true;
private static Object oj = new Object();
@Override
public void run() {
if (flag) {
while (true) {
synchronized (oj) {
sale();
}
}
} else {
while (true) {
sale();
}
}
}
public static synchronized void sale() {
// 前提 多线程进行使用、多个线程只能拿到一把锁。
// 保证只能让一个线程 在执行 缺点效率降低
synchronized (oj) {
if (count > 0) {
try {
Thread.sleep(50);
} catch (Exception e) {
// TODO: handle exception
}
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - count + 1) + "票");
count--;
}
}
}
}
public class ThreadDemo3 {
public static void main(String[] args) throws InterruptedException {
ThreadTrain3 threadTrain1 = new ThreadTrain3();
Thread t1 = new Thread(threadTrain1, "①号窗口");
Thread t2 = new Thread(threadTrain1, "②号窗口");
t1.start();
Thread.sleep(40);
threadTrain1.flag = false;
t2.start();
}
}
2. java内存模型
Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范
Java内存模型规定了所有的变量(这里的变量是指成员变量,静态字段等但是不包括局部变量和方法参数,因为这是线程私有的)都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中拷贝了该线程使用到的主内存中的变量(只是副本,从主内存中拷贝了一份,放到了线程的本地内存中),线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。
可简单类比成计算机内存模型中的主存和缓存,但是和JVM内存结构中的Java堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比.
如下图所示
1. 首先要将共享变量从主内存拷贝到线程自己的工作内存空间,工作内存中存储着主内存中的变量副本拷贝
2. 线程对副本变量进行操作,(不能直接操作主内存)
3. 操作完成后通过JMM 将线程的共享变量副本与主内存进行数据的同步,将数据写入主内存中。
4. 不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成
当多个线程同时访问一个数据的时候,可能本地内存没有及时刷新到主内存,所以就会发生线程安全问题
** JMM是在线程调run方法的时候才将共享变量写到自己的线程本地内存中去的,而不是在调用start方法的时候
3. volatile关键字
3.1 错误实例
class ThreadVolatileDemo extends Thread {
public boolean flag = true;
@Override
public void run() {
System.out.println("子线程开始执行");
while (flag) {
}
System.out.println("子线程执行结束...");
}
public void setFlag(boolean flag){
this.flag=flag;
}
}
public class ThreadVolatile {
public static void main(String[] args) throws InterruptedException {
ThreadVolatileDemo threadVolatileDemo = new ThreadVolatileDemo();
threadVolatileDemo.start();
Thread.sleep(3000);
threadVolatileDemo.setFlag(false);
System.out.println("flag已被修改为false!");
}
}
先看这个代码的执行结果
虽然flag已被修改,但是子线程依然在执行,这里产生的原因就是上面的jmm导致的
由于主线程休眠了3秒,所以子线程没有意外的话是一定会被执行run方法的。而当子线程由于调用start方法而执行run方法时,会将flag这个共享变量拷贝一份副本存到线程的本地内存中。此时线程中的flag为true,即使主线程在休眠后修改了flag值为false,子线程也不会知道,即不会修改自己副本的flag值。所以这就导致了该问题的出现。
注意:在测试时,一定要让主线程进行sleep或其他耗时操作,如果没有这步操作,很有可能在子线程执行run方法 而 拷贝共享变量到线程本地内存之前,主线程就已经修改了flag值。
3.2 解决
当出现这种问题时,就可以使用Volatile关键字进行解决
Volatile 关键字的作用是变量在多个线程之间可见。使用Volatile关键字将解决线程之间可见性, 强制线程每次读取该值的时候都去“主内存”中取值
只需要在flag属性上加上该关键字即可
public volatile boolean flag = true;
子线程每次都不是读取的线程本地内存中的副本变量了,而是直接读取主内存中的属性值。
volatile虽然具备可见性,但是不具备原子性。
重点: 如果一个变量被volatile
关键字修饰,那么对该变量的写操作会立即被写回主内存,同时会使其他线程中的该变量副本失效,这样其他线程在下次访问该变量时就会从主内存中重新读取最新的值。因此,如果你在一个线程的run
方法中修改了一个被volatile
修饰的变量,那么该修改会立即被写回主内存,其他线程在访问该变量时会读取到最新的值,而不是该变量在当前线程中的副本。
4. 原子类
4.1 错误实例
public class VolatileNoAtomic extends Thread {
// 需要10个线程同时共享count static修饰关键字, 存放在静态区, 只会存放一次,所有的线程中都共享
private volatile static int count = 0;
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
count++;
}
System.out.println(getName()+","+count);
}
public static void main(String[] args) {
// 创建10个线程
VolatileNoAtomic[] volatileNoAtomicList=new VolatileNoAtomic[10];
for (int i = 0; i < volatileNoAtomicList.length; i++) {
volatileNoAtomicList[i]=new VolatileNoAtomic();
}
for (int i = 0; i < volatileNoAtomicList.length; i++) {
volatileNoAtomicList[i].start();
}
}
}
创建了10个线程,并且对使用static的全局变量count进行数量修改,结果显示
如果count自增是一个原子性操作,那么最后的结果一定是10000(中间的顺序可能会有所颠倒),因为线程对count一共操作了10000次。但其实自增操作并不是原子性操作,自增操作其实是分了3步的,包括读取变量的原始值、进行加1操作、写入工作内存。所以这个结果是不对的,也证明了volatile所修饰的变量不具备原子性。
那如果我们能保证自增操作是一个原子性操作,那么我们就能保证,只让一个线程进行自增操作。
4.2 使用AtomicInteger
public class VolatileNoAtomic extends Thread {
// 需要10个线程同时共享count static修饰关键字, 存放在静态区, 只会存放一次,所有的线程中都共享,
private static AtomicInteger count = new AtomicInteger(0);
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
count.incrementAndGet();
}
System.out.println(getName()+","+count.get());
}
public static void main(String[] args) {
// 创建10个线程
VolatileNoAtomic[] volatileNoAtomicList=new VolatileNoAtomic[10];
for (int i = 0; i < volatileNoAtomicList.length; i++) {
volatileNoAtomicList[i]=new VolatileNoAtomic();
}
for (int i = 0; i < volatileNoAtomicList.length; i++) {
volatileNoAtomicList[i].start();
}
}
}
然后结果发现每次最后一次打印都是10000,即解决了原子性问题
5. synchronized,volatile 和Atomicxx的区别
synchronized 不仅保证可见性,而且还保证原子性,synchronized保证了synchronized块中变量的可见性。只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞。
volatile 保证了所修饰的变量的可见性,不保证原子性。因为volatile只是在保证了同一个变量在多线程中的可见性,所以它更多是用于修饰作为开关状态的变量,即Boolean类型的变量。在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制(只保证变量可见,不保证同步)volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢。
Atomic修饰的类如AtomicInteger 保证了数据的原子性。 由于使用的CAS,在进行比如说自增操作时,如果有线程在执行自增操作,那么此线程会一直循环尝试运行自增操作,直到成功。而不会让线程进入堵塞状态。 进行的操作相对于synchronized修饰的代码块会有很大性能上的提升。建议在计数器相关的多线程并发中用Atomic开头的相关类进行操作。
总结一下:volatile多用于修饰类似开关类型的变量;
Atomic多用于类似计数器相关的变量;
其它多线程并发操作用synchronized关键字修饰(还有Lock接口的一系列实现)。
6. 多线程通信
线程通信的目标是使线程间能够互相发送信号。另一方面,线程通信使线程能够等待其他线程的信号。
例如,线程B可以等待线程A的一个信号,这个信号会通知线程B数据已经准备好了
通信方式有很多种,比如通过共享变量来通信,或者wait,notify等
6.1 wait(),notify()和notifyAll()
只能使用在synchronized 同步代码块中,不能使用在Lock锁中
一个线程一旦调用了任意对象的wait()方法,就会变为非运行状态,直到另一个线程调用了同一个对象的notify()方法。为了调用wait()或者notify(),线也程必须先获得那个对象的锁。就是说,线程必须在同步块里调用wait()或者notify(),且使用同一个锁对象调用wait,notify
如果对象调用了wait方法就会使持有该对象的线程把该对象锁的控制权交出去,然后处于等待状态。
如果对象调用了notify方法就会通知某个正在等待这个对象的控制权的线程可以继续运行。
public class MonitorObject{
}
public class MyWaitNotify{
MonitorObject myMonitorObject = new MonitorObject();
public void doWait(){
synchronized(myMonitorObject){
try{
myMonitorObject.wait();
} catch(InterruptedException e){...}
}
}
public void doNotify(){
synchronized(myMonitorObject){
myMonitorObject.notify();
}
}
}
等待线程将调用doWait(),而唤醒线程将调用doNotify()。当一个线程调用一个对象的notify()方法,正在等待该对象的所有线程中将有一个线程被唤醒并允许执行(校注:这个将被唤醒的线程是随机的,不可以指定唤醒哪个线程)。同时也提供了一个notifyAll()方法来唤醒正在等待一个给定对象的所有线程。 一个线程如果没有持有对象锁,将不能调用wait(),notify()或者notifyAll()。否则,会抛出IllegalMonitorStateException异常。
一旦一个线程被唤醒,不能立刻就退出wait状态,直到调用notify()的线程退出了它自己的同步块且还需要wait线程能抢到锁才能继续执行。线程被唤醒后,是从上一次被wait的地方继续向下执行,而不是重新执行代码。
当有多个线程wait,调用notifyAll时,会唤醒全部持有该锁的wait状态线程,但是只有一个wait线程能抢到锁而继续执行,其他线程依然需要进行抢锁,直到抢到了锁才能完全退出wait状态
6.2 wait与sleep区别?
对于sleep()方法,我们首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。
sleep()方法导致了程序暂停执行指定的时间,让出cpu给其他线程,但不释放锁,当指定的时间到了又会自动恢复就绪状态。只要抢到了时间片段即可执行
而当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备,然后需要抢到锁和cpu执行权才能进入执行状态
7. 停止线程
停止线程有3中方式
- 使用退出标识,使得线程正常退出,即当run方法完成后进程终止。
- 使用stop强行中断线程(此方法为作废过期方法),不推荐使用,暴力终止,可能使一些清理性的工作得不到完成。还可能对锁定的内容进行解锁,容易造成数据不同步的问题。
- 使用interrupt方法中断线程
7.1 使用退出标识
class StopThread extends Thread {
private volatile boolean flag = true;
@Override
public void run() {
System.out.println("子线程开始....");
while (flag) {
}
System.out.println("子线程结束....");
}
public void stopThread() {
flag = false;
}
}
public class StopThreadDemo {
public static void main(String[] args) {
StopThread stopThread = new StopThread();
stopThread.start();
stopThread.stopThread();
}
}
7.2 interrupt
7.2.1 处理堵塞状态的线程
当线程处于堵塞状态下,那么直接使用退出标识的方式可能就会存在结束不了线程的问题。可以使用interrupt来结束(并不会直接结束进程),如下代码所示,当调用了线程的interrupt方法后,如果线程是堵塞的,那么就会抛出异常,那我们只要捕获异常,并使用退出标识变量去结束线程即可。如果线程没有堵塞,调用interrupt方法只会修改线程的interrupt标识状态,并不会抛出异常
class StopThread extends Thread {
private volatile boolean flag = true;
@Override
public synchronized void run() {
System.out.println("子线程开始....");
while (flag) {
try {
sleep(200L);
} catch (InterruptedException e) {
System.out.println("捕获了异常:"+ e.getMessage());
stopThread();
}
}
System.out.println("子线程结束....");
}
public void stopThread() {
flag = false;
}
}
public class StopThreadDemo {
public static void main(String[] args) {
StopThread stopThread = new StopThread();
stopThread.start();
stopThread.interrupt();
}
}
7.2.2 处理非堵塞状态线程
当线程被调用interrupt方法后,会修改线程的interrupt状态值为true
在Thread.java类里提供了两种方法判断线程的interrupte状态。
Thread.interrupted()和this.isInterrupted()
注意这两个方法不要多次调用,因为interrupt状态值在第一次被调用后就重新置为了false,源码如下:
public static boolean interrupted() {
return currentThread().isInterrupted(true);
}
两者的区别就是一个是静态的方法,一个是对象的方法。静态的方法只能判断当前线程的interrupt状态值,对象形式的可以判断其他线程的interrupt状态值。
所以在代码中我们只需要使用这两个方法,再配合退出标识就能正常结束线程
class StopThread extends Thread {
private volatile boolean flag = true;
@Override
public synchronized void run() {
System.out.println("子线程开始....");
while (flag) {
// if (Thread.interrupted()) {
// stopThread();
// }
if (isInterrupted()) {
stopThread();
}
}
System.out.println("子线程结束....");
}
public void stopThread() {
flag = false;
}
}
public class StopThreadDemo {
public static void main(String[] args) {
StopThread stopThread = new StopThread();
stopThread.start();
stopThread.interrupt();
}
}
7.2.3 interrupt补充
如果你的线程是堵塞状态,然后你既使用Thread.interrupted()又使用了sleep(200L),此时sleep并不会抛出异常
class StopThread extends Thread {
private volatile boolean flag = true;
@Override
public synchronized void run() {
System.out.println("子线程开始....");
while (flag) {
try {
if (Thread.interrupted()) {
sleep(200L);
stopThread();
}
} catch (InterruptedException e) {
System.out.println("捕获了异常:"+ e.getMessage());
}
}
System.out.println("子线程结束....");
}
public void stopThread() {
flag = false;
}
}
public class StopThreadDemo {
public static void main(String[] args) {
StopThread stopThread = new StopThread();
stopThread.start();
stopThread.interrupt();
}
}
结果是:
8. ThreadLocal
ThreadLocal提供一个线程的局部变量,使得每个线程都拥有自己局部变量。
当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
ThreadLocal的接口方法常用的就只有set()指定值,get()获取值,和remove删除
public class ThreadLocalDemo {
public static void main(String[] args) {
ShareObj so = new ShareObj();
ThreadDemo d1 = new ThreadDemo(so);
ThreadDemo d2 = new ThreadDemo(so);
ThreadDemo d3 = new ThreadDemo(so);
d1.start();
d2.start();
d3.start();
}
}
class ShareObj {
private ThreadLocal<Integer> local = new ThreadLocal<>();
private ThreadLocal<Integer> local2 = new ThreadLocal<>();
public void setNum(Integer num,Integer num2) {
this.local.set(num);
this.local2.set(num2);
}
public void increaseNum() {
this.local.set(this.local.get() +1);
this.local2.set(this.local2.get() +1);
}
public Integer getNum () {
return this.local.get();
}
public Integer getNum2 () {
return this.local2.get();
}
}
class ThreadDemo extends Thread{
private ShareObj so;
public ThreadDemo(ShareObj so) {
this.so = so;
}
@Override
public void run() {
if (so.getNum() == null) {
so.setNum(0,10);
}
for (int i = 0 ;i< 3 ;i++) {
so.increaseNum();
System.out.println(Thread.currentThread().getName() +":"+ so.getNum() +","+so.getNum2());
}
}
}
以上代码结果是
结果展示,即使我3个线程使用的是同一个对象ShareObj,但是ThreadLocal中的值依然互不影响。
我之所以使用两个ThreadLocal<Integer>,是因为我一开始以为是用Thread当前线程作为key的,如果是这样,那么值一定会被覆盖,通过实践和源码发现,并不是
ThreadLocal的源码就是使用ThreadLocalMap来存储值的,key值代码ThreadLocal这个对象,并不是Thread,而value值就是你set进去的值
注意: 在使用ThreadLocal时注意,当你set值的时候一定要在已经新建的线程中执行。比如我在进行ThreadDemo线程对象的初始化操作时(new)去设置ShareObj的ThreadLocal值,会发现其实这个设置的值是放在main线程的本ThreadLocal中的。所以一般设置ThreadLocal初始值时,都在run方法中去执行