多线程带来的风险——线程安全及解决机制

上一篇:操作系统的进程与线程知识汇总

上一篇里给出了线程安全的定义和导致线程不安全的因素,接下来让我们看一下导致线程不安全的三个原因及其解决办法。这三个原因也就是和线程安全有关的三个特性:原子性,可见性,代码顺序性。

线程不安全的原因

一、原子性

1、什么是原子性

原子性就是一组指令它的作用效果不能被中间断开(即“同生共死”)。

2、关于变量赋值是否是原子性的

【知识回顾】:Java中的一条Java语句,不一定只有一条指令,实际上可能由多条指令组成。
比如:n++是一条Java语句,但不是一条指令;n++是先将n加上1,再将加过的值赋给n。

Int a =;int b =;
A = b;  //不是原子的
Int a;
A = 10;  //是原子的
Long a;
A = 10L;  //不是原子的
//因为JVM每次操作大小是按32位设计的
//而long是64位的,所以a = 10L;a的高32位 = 0;a的低32位 = 10;
Float是原子的,double不是
A是个引用类型,把某个引用的值写入a的这个过程是原子的,
A = null,是原子的

2、如何保证原子性

最简单的办法就是加锁。

锁:具有互斥性,即一个线程持有锁时,其他线程无法拥有该锁。
lock+中间的操作+unlock可以保证中间的操作具备原子性,中间的修改是不会被中断的。抢锁是需要运行lock的,所以需要先抢到CPU,如果CPU上没有抢到锁,会导致该线程被从CPU上切换走;同时,暂时不具备抢CPU的资格。

二、可见性

1、什么是可见性问题

从CPU缓存中读写远远快于从内存中读写,从CPU缓存中读写减少了对内存的读写,进行提升代码的执行速度。同时高速缓存也带来一个问题:我们执行的操作的中间值,全部只保留在高速缓存中,内存中是没有及时变化的。

这样就会导致一个问题:如果有多个CPU的话,每个CPU都有自己的高速缓存,而CPU之间共享数据是用内存的,当前计算的结果,其他CPU上正在执行的代码是看不到的。

那么这套机制带来的问题就被称为内存的可见性问题:无法保证内存中的数据各个CPU之间看到的是一致的

2、工作内存与主内存

Java使用的JVM进行代码运行。JVM是一个虚拟机,虚拟硬件和操作系统。JVM在运行线程代码时,会把内存看成两部分:每个线程都有自己的工作内存:

工作内存——类比上面的CPU高速缓存
主内存——类比上面的内存。

规定:线程在运行代码时:
I) 通过load把数据从主内存加载到工作内存;
II) 接下来的所有操作都在工作内存中完成,线程不能直接操作主内存上的数据;
III) 计算结束后,选择合适的时机把工作内存上的数据save(刷新)回主内存中。

3、如何解决内存可见性带来的问题?

在合适的位置,通过一些指令要求来解决(同上以n++举例):
I) n的操作线程必须把工作内存中的数据全部刷新主内存,保证主内存的数据一定是最新的了;
II) 同时把其他线程的工作内存置为过期(失效),目的是使其他线程需要n的值时,必须从主内存中重新加载。保证其他线程一定通过主内存读取到了最新值,不要再使用他们之前自己拥有的值。

三、代码重排序

1、什么叫做代码重排序

简单去讲,就是指令的最终执行顺序,可能不是我们代码的书写顺序。

单线程情况下,不会因为这个重排序导致结果出现问题。

2、为什么要重排序

代码重排序是为了提升执行效率,程序写的代码顺序往往不是最优解。

3、谁会对代码进行重排序

I) 编译器就有可能进行重排序(根据他自己知道的信息,重排序代码)
II) 运行阶段,JVM也有可能进行重排序(运行起来之后,JVM知道的信息要比编译器多)
III) 运行阶段,CPU指令上也会进行一定的重排序。

4、重排序在多线程情况下可能引发的问题

在多线程情况下可能会因为重排序导致结果不正确了。

5、如何解决上述问题

I) Java原生规定了一些行为必须在另一行为之前,这个顺序是不能被重排序的(happened before);
II) 还提供了一些机制,显示了重排序的自由度。

所以上一篇中提到的使多线程产生线程不安全的两个因素——有共享,并且有修改时才需要特意考虑线程安全问题。而在上述情况下,一定是违背了原子性,可见性,代码重排序 导致的问题。


如何保证线程安全:

I) 在设计多线程代码时,尽量不要让多线程之间共享资源——天生线程安全
II) 满足不了时,尽量只是读共享资源,而不要修改共享资源——天生线程安全

例如:不可变对象——A线程传给B线程的是一个不可变的对象0,
则B修改不了0,所以不用考虑线程安全问题。

III) 如果必须共享+修改,就可以使用下面给出的很多机制,来保证原子性,可见性,重排序后的正确性即可。

一、加锁机制(同步机制)

1、Synchronized关键字

Java中提供了一种最简单的加锁机制(同步机制)。
同步即两线程之间不再是孤独的了,需要相互考虑对方的情况。
Synchronized(同步) 作用就是一把锁,实现两个线程之间互斥。
语法作用:作为方法的修饰符,可以写在定义的方法之前;作为代码块出现。

Synchronized 代码块中,争夺的是引用指向的对象中的锁
Synchronized 修饰普通方法,争夺的是this引用指向的对象中的锁
Synchronized 修饰静态方法,争夺的是类.class引用指向的对象中的锁

public class Synchronized语法演示 {
    
    synchronized void 普通方法() {
        //争夺的是this指向对象中的锁
    }
    
    synchronized static void 静态方法() {
        //争夺的是Synchronized语法演示.class指向的对象中的锁
        //反射用法:class本身也有一个对象,可以通过类名.class引用找到这个对象
    }
    
    void 其他方法() {
        Object 一个引用 = new Object();
        synchronized (一个引用) {
            //争夺的是一个引用指向的对象中的锁
        }
    }
}

2、加上Synchronized为什么就没问题了?

因为Synchronized 同步代码块小括号中利用这个引用指向的对象作为锁。
语法会在大括号开始时插入一个字节码——lock;
语法会在大括号结束时插入一个字节码——unlock。

3、抢锁失败之后,线程状态是如何变化?

Runnable->Blocked

Blocked状态是专为了Synchronized抢锁失败而用,其他情况不会进入这个状态。

4、判断线程之间是互斥还是不互斥?

A和B线程的那种互相抢锁的行为我们称之为互斥。
判断标准如下:
I) 他们是不是在争夺锁?
II) 他们是不是在争夺同一把锁?

5、解引用

解引用通常可以认为就是根据引用来存取资源或存取值。
因为Synchronized需要对象中的锁,所以也是一个解引用的过程。
Object o = null;
Synchronized (o) { }; 一定会抛异常。

6、Synchronized锁大概的过程:

1)申请锁,申请成功才能继续执行下面的代码;
2)失败,则放弃CPU,放弃抢CPU的资格(running->blocked);
3)当锁被释放时,之前曾经抢这把锁失败的线程们重新拥有抢CPU资格(blocked-> running);
4)首先先抢CPU;
5)抢到CPU之后,需要继续去抢锁。

7、Synchronized解决了哪些问题?

I)原子性
保证lock到unlock结束的这段指令,不会被其他互斥线程中断(例外:没有线程互斥,则毫无保证)

Ii)适度的保证可见性(只在互斥的线程之间)
Synchronized释放锁的时候:强制把当前锁的持有线程的工作内存刷新到主内存中。(首先两线程得是互斥的)
Synchronized请求成功锁的时候:强制把当前锁的持有线程工作内存清空掉,要求重新从主内存中读取。(一头一尾才能保证,中间不管)

Iii)可以适度的影响到重排序

二、volatile关键字

1、语法

属性/静态属性的修饰符,修饰在变量定义的位置。

2、volatile的作用

第一个作用:被volatile修饰的变量,无论什么类型,都是原子的;
第二个作用:使得被volatile修饰变量具备可见性结果;
第三个作用:volatile Person p;
P先被volatile修饰时,p = new person(…);这一套是不允许被重排序的,先new,再调用构造方法,再赋值引用来进行。

三、wait/notify(notifyAll)

1、这些方法是属于object的方法。不是thread的方法。Object是所有类的父类,所以这些方法是所有类都具有的。

2、作用

Object o= new object();
当A线程调用o.wait()之后,A线程会放弃CPU,并且失去抢夺CPU的资格(running->waiting);A线程从就绪队列被移到o指向对象的等待集(wait sets)。

A线程开始等,等到有线程唤醒他。当B线程调用o.notify()之后,B本身没有什么变化(依然在CPU上继续执行),但是会把o指向对象的等待集上的任意一个线程从等待集上移到就绪队列中,并且把这个线程的状态变更(waiting->runnable),使得一个线程重新拥有了抢CPU资格。B线程就唤醒了o指向对象等待集上的一个线程。

当B线程调用o.notifyAll()时,会被o等待集上的所有等待线程都唤醒。

3、语法上的要求

因为wait/notify(notifyAll)事实上都会修改o指向对象的等待集,为了让这个过程保持原子性,Java规定,无论调用wait还是notify(notifyAll)必须首先请求o这个对象上的锁。

4、wait的调用过程中会首先unlock这个锁,当被唤醒时,需要重新lock这个锁。

5、Notify的唤醒过程没有保留机制,notify只能唤醒之前wait的线程,之后的无法处理。


下一篇:初阶多线程(续)
在这里插入图片描述

你可能感兴趣的:(JavaWeb,java,多线程,安全)