多线程安全(synchronized、三大特性、生命周期以及优缺点)

一、线程安全

一个对象是否安全取决于它是否被多个线程访问(访问是访问对象的方式)。要使对象线程安全,name需要采用同步的机制来协同对对象可变状态的访问。(java这边采用synchronized,其他还有volatile类型的变量,显式锁以及原子变量)

 

当某个多线程访问同一个可变状态时候没有同步,则会出现错误,解决方法:

1、不在线程之间共享该变量

2、将该变量修改为不可变变量

3、访问状态时候使用同步

 

安全性的解释:当多线程访问某个类时,这个类始终能表现出正确的行为(不管运行时采用何种跳读方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同),那么这个类是安全的。

  • 无状态对象一定是线程安全的。

  • 在实际情况下,尽可能使用户现有的线程安全对象,比如说用vector 而不是 ArrayList。

要想并发程序正确地执行,必须要保证“原子性”,“可见性”和"有序性"。只要有一个没有被保证,就有可能导致程序运行不正确。

二、并发三大特性

先说说并发,并发是指 在操作系统中,一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,担任一个时刻点上只有一个程序在处理机上运行。

 

1、原子性

原子性的意思代表着————“不可分割”,很多操作都是非原子性。

 

 

a.竞态条件

当某个计算的正确性取决于多个线程的交替执行顺序时,那么就会发生竞态条件,也就是说,正确的结果取决于运气。竞态条件和原子性相关,或者说,之所以代码会发生竞态条件,就是因为代码不是以院子方式操作的,而是一种复合操作。

在多线程没有同步的情况下,多种操作序列的执行时序发生变化导致错误(是指设备或系统出现不恰当的执行时序,而得到不正确的结果)。即类型为先检查后执行操作。


Class NotSafeThread{
    int i = 0;
    public void Nst(){
        if(i==0){
            //这边就会出现竞态,如果两个线程AB都运行这边,A判断 i == 0, i++操作 
            //那么i为1, 但是B线程也在A判断的时候判断为True, 又进行一次 i++, i=2了
            i++;
        }
    }
}

 

b.复合操作

我们将 “先检查后执行” 和 “读取+修改+写入” 等操作的院子形式成为复合操作:包含一组必须以院子方式执行的操作以确保线程安全。

 

c.原子操作(atomic operations)

原子操作指的是在一步之内就完成而且不能被中断

//比如 多线程中 int i = 0; i++; 多个线程的时候会出现问题。
++count 看起来是一个操作,但这个操作并非原子性的,因为它可以被分成三个独立的步骤:①读取count的值 ②值+1 ③将计算结果写入count 这是一个“读取-修改-写入” 的操作序列,并且结果状态依赖于之前的状态。


public void service(ServletRequest req,ServletResponse resq){
    BigInteger i = extractFromRequest(req);
    BigInteger[] factor= factor(i);
    ++count;
    encodeIntoResponse(resp,factors);

}

 

 x = 10;        //语句1 
 y = x;         //语句2
 x++;           //语句3
 x = x + 1;     //语句4

只有语句1是原子性操作,其他三个语句都不是原子性操作。

语句1是直接将数值10赋值给 x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。

语句2实际上包含2个操作,它先要去读取x的值,在将x的值写入工作内存,虽然读取 x 的值以及将 x 的值写入到工作内存,这两个操作都是原子性操作,但是合起来就不是原子性操作了。

同样的,x++ 和 x = x + 1 包括三个操作: 读取 x 的值,进行 +1 操作,写入新的值。

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

 

 

2、可见性

synchronized 关键值,开始时会从内存中读取,结束时会将变化刷新到内存中,所以是可见的。

volatile 关键值,通过添加lock指令,也是可见的。

可见性

  • 多线程环境下,一个线程对于某个共享变量的更新,后续访问该变量的线程可能无法立即读取到这个更新的结果,这就是不可兼得情 况。

  • 可见性就是指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题。

  • 可见性和原子性的联系和区别:

    • 原子性描述的是一个线程对共享变量的更新,从另一个线程的角度来看,它要么完成,要么尚未发生。

    • 可见性描述一个线程对共享变量的更新对于另一个线程而言是否可见。


//普通情况下,多线程不能保证可见性

bool stop = false;

new Thread(() -> { 
    System.out.println("Ordinary A is running..."); 
    while (!stop) ;
    System.out.println("Ordinary A is terminated."); 
}).start(); 

Thread.sleep(10); 
new Thread(() -> { 
    System.out.println("Ordinary B is running..."); 
    stop = true; 
    System.out.println("Ordinary B is terminated."); 
}).start();

某次运行结果: Ordinary A is running... Ordinary B is running... Ordinary B is terminated.


 

3、有序性(可见性是有序性的基础)

被 synchronized 修饰的代码只能被当前线程占用,避免由于其他线程的执行导致的无序。

volatile 关键字包含了禁止指令重排序的语义,使其具有有序性。

 

有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:


int i = 0;
boolean flag = false;
i = 1;                // 语句1 
flag = true;          // 语句2


上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,nameJVM在真正执行这段代码的时候会保证 语句1 一定会在 语句2 前面执行吗? 不一定,为什么呢? 这里可能会发生指令重排序(Instruction Reorder)

 

a.重排序

下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会输入代码进行优化,它不保证程序中哥哥语句的执行先后顺序同代码中的顺序一致,但是他会保证程序最终执行结果和代码顺序执行的结果是一致的。比如上面的代码中,语句1 和 语句2 谁限制性对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2 先执行而 语句1 后执行。

但是要之一,虽然处理器会对指令进行重新排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:


int a = 10;    //语句1 
int r = 2;     //语句2 
a = a + 3;     //语句3 
r = a*a;       //语句4

这段代码有4个语句,那么可能的一个执行顺序是:

那么可不可能是这个执行顺序呢:语句2 语句1 语句4 语句3

不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2 必须用到 Instruction 1 的结果,那么处理器会保证 Instruction 1 会在 Instruction 2 之前执行。

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢,? 
下面看一个例子:

//线程1: 
context = loadContext();   //语句1 
inited = true;             //语句2   

//线程2: 
while(!inited ){
       sleep(); 
} doSomethingwithconfig(context);

   上面代码中,由于 语句1 和 语句2 ,没有数据依赖性, 因此可能会被重排序。假如发生了重排序,在线程 1 执行过程中先执行 语句2,而此时线程2 会以为初始化工作已经完成,那么就会跳出 while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

所以:
  • 重排序可能导致线程安全问题

  • 重排序不是必然出现的

  • 指令重排序不会影响单个线程的执行,但是会影响线程并发执行的正确性。

 

b.先行发生原则

锁的在临界区"许进不许出"原则: (什么是临界区?)

临界区外的语句可以被编译器重排序到临界区之外,但是临界区之内的语句不可以重排序到临界区之外

多个临界区的具体规则:

锁申请和锁释放不能被重排序

两个锁申请操作不能被重排序

两个锁释放操作不能被重排序

解释:

Java虚拟机会在临界区的开始之前和结束之后分别插入一个获取屏障和释放屏障,从而进制临界区内的操作被排到临界区之前和之后

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。
如果两个操作的执行次序从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

 

下面就来具体介绍下happens-before原则(先行发生原则):

  • 程序次序规则 : 一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作(因为虚拟机可能对程序代码进行了指令重排序。虽然进行了重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。

  • 锁定规则 : 一个unLock操作先行发生于后面对同一个锁的lock操作

  • volatile 变量规则 : 对一个变量的写操作先发生于后面对这个变量的操作

  • 传递规则 : 如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A发生于操作C

  • 线程启动规则 : Thread对象的start()方法先行发生于此线程的每一个动作

  • 线程中断规则 : 对线程interrupt()方法的调用先行发生于被中断线程的艾玛检测到中断事件发生

  • 线程终结规则 : 线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

  • 对象终结规则 : 一个对象的初始化完成先行发生于它的finalize()方法的开始

 

 

三、线程的生命周期

 

新建状态(New):当线程对象创建后,即进入了新建状态,如:Thread t = new MyThread();

 

就绪状态(Runable):当调用线程对象的start() 方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,及时等待CPU调用执行,并不是说执行了t.start() 此线程立即就会执行;

 

运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中。

 

阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:

1.等待阻塞:运行状态中的线程执行wait()方法,是本线程进入到等待阻塞状态;

2.同步阻塞:线程在获取synchronized 同步锁失败(因为锁被其他线程所占用),它会进入同步阻塞状态;

3.其他阻塞:通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

 
死亡状态(Dead):线程执行完或因异常退出run()方法,该线程结束生命周期。

 

 

四、多线程的优缺点

 

1、多线程编程的优势:

  • 提高系统的吞吐率

  • 提高响应性

  • 充分利用多核处理器资源

  • 最小化对系统资源的使用

  • 简化程序的结构

 

 
2、多线程编程的风险:

注意:因为在没有充足的情况下,多个线程的执行顺序是不可预测的,所以线程的安全需要注意。

 
1、上下文切换:时间片是CPU分配给各个线程的时间,因为时间非常短,所以CPU不断通过切换线程,让我们觉得多个线程是同时进行的,时间片一般是几十毫秒。而每次切换时,需要保存当前的状态,一遍能够进行恢复先前状态,而这个切换是非常损耗性能的,过于频繁反而无法发挥出多线程编程的优势。通常减少上下文切换可以采用无锁并发编程,CAS算法,使用最少的线程和使用协程。

 
2、并发安全:多线程编程中最难以把握的就是临界区线程安全问题,稍微不注意就会出现死锁的情况,一旦产生死锁就会造成系统功能不可用。

  • 线程活性

    • 死锁

    • 活锁:

    • 饥饿

    • 锁死

  • 避免死锁:

1.避免一个线程同时获得多个锁;    

2.避免一个线程在锁内部占有多个资源,尽量保证每个锁占用一个资源;

3.常识使用定时锁,使用lock.tryLock(timeOut),当超时等待时当前线程不会阻塞;

4.对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况

 

3、线程安全

  • 原子性

  • 有序性

  • 可见性

 

 

五、多线程的作用:

 

挖掘出程序中的多并发点是KEY。

  • 并发分而治之(一个复杂任务分解成多个简单任务 fork后join)

    • 按照任务的资源消耗属性分割

      • 系统资源使用情况

      • CPU上限

      • 稀缺资源(数据库连接等)

    • 按照步骤分割

  • 并发实现大量数据的拆解(如下载器就用了多并发)

  • 设置出合理的线程数

    • Amdahl定律(阿姆达尔定律)

    • 常见考虑原因

 

 
 

 

posted @ 2020-02-07 18:48  九角冰山  阅读(1801)  评论(0编辑  收藏  举报