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

 

一、线程安全

一个对象是否安全取决于它是否被多个线程访问(访问时访问对象的方式)。要使对象线程安全,那么需要采用同步的机制来协同对对象可变状态的访问。(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的值  ②值加一  ③将计算结果写入count  这是一个”读取-修改-写入”的操作序列,并且结果状态依赖于之前的状态。 public void service(ServletRequest req, ServletResponse resp) { BigInteger i = extractFromRequest(req); BigInteger[] factors = 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的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。

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

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

 

 

2、可见性

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

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

可见性

  • 多线程环境下,一个线程对于某个共享变量的更新,后续访问该变量的线程可能无法立刻读取到这个更新的结果,这就是不可见的情况。
  • 可见性就是指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题
  • 可见性和原子性的联系和区别:
    • 原子性描述的是一个线程对共享变量的更新,从另一个线程的角度来看,它要么完成,要么尚未发生。
    • 可见性描述一个线程对共享变量的更新对于另一个线程而言是否可见

 

//普通情况下,多线程不能保证可见性 bool stop = falsel; 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前面的,那么JVM在真正执行这段代码的时候会保证语句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();

 

就绪状态(Runnable):当调用线程对象的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后jion )
      • 按照任务的资源消耗属性分割
        • 系统资源使用情况
        • CPU上限
        • 稀缺资源(数据库连接等)
      • 按照步骤分割
  • 并发实现大量数据的的拆解(如下载器就用了多并发)
  • 设置出合理的线程数
      • Amdahi's定律
      • 常见考虑原因

 

 

 

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值