并发系列——基础篇(一)

本系列并发编程主要参考《Java并发编程之美》一书。

第一章 并发编程线程基础

1、线程

操作系统在分配资源时是把资源分配给进程的, 但是 CPU 资源比较特殊, 它是被分配到线程的, 因为真正要占用 CPU 运行的是线程, 所以也说线程是 CPU 分配的基本单位。
(图1-1)

    由图 1-1 可以看到, 一个进程中有多个线程,多个线程共享进程的堆和方法区资源(共享资源考虑安全), 但是每个线程有自己的程序计数器和栈区域。
    程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。 那么为何要将 程序计数器设计为线程私有的呢?前面说了线程是占用 CPU 执行的基本单位,而 CPU 一 般是使用时间片轮转方式让线程轮询占用的,所以当前线程 CPU 时间片用完后,要让出 CPU,等下次轮到 自 己的时候再执行。 那么如何知道之前程序执行到哪里了呢?其实程序 计数器就是为了记录该线程让出 CPU 时的执行地址的,待再次分配到时间片时线程就可 以从自己私有的计数器指定地址继续执行。 另外需要注意的是,如果执行的是 native 方法, 那么 pc 计数器记录的是 undefined 地址,只有执行的是 Java 代码时 pc 计数器记录的才是 下一条指令的地址。
    另外每个线程都有自 己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外枝还用来存放线程的调用栈帧。
    堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分 配的,堆里面主要存放使用 new 操作创建的对象实例。
    方法区则用来存放 JVM 加载的类、常量及静态变量等信息,也是线程共享的。       --------(jvm知识)

2、线程的创建

Java 中有三种线程创建方式,分别为实现 Runnable 接口的 run 方法,继承 Thread 类 并重写 run 的方法,使用 FutureTask 方式。(当然还有去线程池里取)
1)、继承 Thread 类 并重写 run 的方法

public c l ass ThreadTest { 
//继承Thread类并重写run方法 
    public static class MyThread ext ends Thread { 
        @Override 
        public void run () { System.out.println (” I am a chil d thr ead" ) ;}
      }

    public static void main (String[] args) { 
    //创建线程
     MyThread thread= new MyThread (); 
    // 启动线程 
    thread.start(); 
     }
}

    如上代码中的 MyThread 类继承了 Thread 类,并重写了 run() 方法。在 main 函数里 面创建了一个 MyThread 的实例,然后调用该实例的 start 方法启动了线程。需要注意的是, 当创建完 thread 对象后该线程并没有被启动执行,直到调用了 start 方法后才真正启动了 线程。
    其实调用 start 方法后线程并没有马上执行而是处于就绪状态, 这个就绪状态是指该 线程已经获取了除 CPU 资源外的其他资源,等待获取 CPU 资源后才会真正处于运行状态。 一旦 run 方法执行完毕, 该线程就处于终止状态。
使用继承方式的好处是, 在 run() 方法内获取当前线程直接使用 this 就可以了,无须 使用 Thread.currentThread() 方法; 不好的地方是 Java 不支持多继承,如果继承了 Thread 类, 那么就不能再继承其他类。另外任务与代码没有分离, 当多个线程执行一样的任务时需要多份任务代码,而 Runable 则没有这个限制。下面看实现 Runnable 接口 的 run 方法方式。
2)、实现 Runnable 接口的 run 方法

public static class RunableTask implements Runnable{ 
    @Override 
    public void run( ) { System.out.println (” I am a child thread" ) ; 
    }
}
//任务与代码分离,实现接口(java单继承,实现则没限制)
public static void main(String[] args) throws InterruptedException{
    RunableTask task =new RunableTask(); 
    new Thread(task).start() ; 
    new Thread(task).start() ; 
}

    如上面代码所示,两个线程共用一个 task 代码逻辑,如果需要,可以给 RunableTask 添加参数进行任务区分。另外, RunableTask 可以继承其他类。但是上面介绍的两种方式 都有一个缺点,就是任务没有返回值。 下面看最后一种,即使用 FutureTask 的方式。
3)、使用 FutureTask 方式

//创建任务类,类似Runable 
public stat工c class CallerTask implements Callable<Str工ng>{
    @Override
    publiC String call () throws Except工on { 
            return "hello ”;
    }
}

public static void main(String[) args) throws InterruptedException { 
    // 创建异步任务 
    FutureTask<String> futureTask =new FutureTask<>(new CallerTask()) ; 
    //启动线程
    new Thread (futureTask).start () ; 
    try { 
        //等待任务执行完毕,并返回结果 
        String result = futureTask.get (); 
        System.out.println(result); 
    } catch (ExecutionException e) { e.printStackTrace() ; }
     
}

    如上代码中的 CallerTask 类实现了 Callable 接口的 call()方法。在 main 函数内首先创 建了 一个 Futrue Task 对象( 构造函数为 CallerTask 的实例), 然后使用创建的 FutrueTask 对象作为任务创建了一个线程并且启动它, 最后通过 fu归reTask.get() 等待任务执行完毕并 返回结果。


小结 : 使用继承方式的好处是方便传参,你可以在子类里面添加成员变量,通过 set 方法设置参数或者通过构造函数进行传递,而如果使用 Runnable 方式,则只能使用主线 程里面被声明为 final 的变量。不好的地方是 Java 不支持多继承,如果继承了 Thread 类, 那么子类不能再继承其他类,而 Runable 则没有这个限制。前两种方式都没办法拿到任务 的返回结果,但是 Futuretask 方式可以。
 

3、线程的五个状态:

一、五个线程状态:

1. 新建(NEW):新创建了一个线程对象。

2. 可运行(RUNNABLE):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权 。

3. 运行(RUNNING):可运行状态(runnable)的线程获得了cpu 时间片(timeslice) ,执行程序代码。
4. 阻塞(BLOCKED):阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice 转到运行(running)状态。阻塞的情况分三种: 

(一). 等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
(二). 同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。
(三). 其他阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。

5. 死亡(DEAD):线程run()、main() 方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

二.初始状态

  1. 实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态

三.可运行状态

  1. 可运行状态只是说你资格运行,调度程序没有挑选到你,你就永远是可运行状态。
  2. 调用线程的start()方法,此线程进入可运行状态。
  3. 当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入可运行状态。
  4. 当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入可运行状态。
  5. 锁池里的线程拿到对象锁后,进入可运行状态。

四.运行状态

  1. 线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一一种方式。

五.死亡状态

  1. 当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它死去。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦死亡,就不能复生。
  2. 在一个死去的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

六.阻塞状态

  1. 当前线程T调用Thread.sleep()方法,当前线程进入阻塞状态。
  2. 运行在当前线程里的其它线程t2调用join()方法,当前线程进入阻塞状态。
  3. 等待用户输入的时候,当前线程进入阻塞状态。

七.等待队列(本是Object里的方法,但影响了线程)

  1. 调用obj的wait(), notify()方法前,必须获得obj锁,也就是必须写在synchronized(obj) 代码段内。
  2. 与等待队列相关的步骤和图

八.锁池状态

  1. 当前线程想调用对象A的同步方法时,发现对象A的锁被别的线程占有,此时当前线程进入锁池状态。简言之,锁池里面放的都是想争夺对象锁的线程。
  2. 当一个线程1被另外一个线程2唤醒时,1线程进入锁池状态,去争夺对象锁。
  3. 锁池是在同步的环境下才有的概念,一个对象对应一个锁池。

九.几个方法的比较

  1. Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入阻塞,但不释放对象锁,millis后线程自动苏醒进入可运行状态。作用:给其它线程执行机会的最佳方式。
  2. Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的cpu时间片,由运行状态变会可运行状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。
  3. t.join()/t.join(long millis),当前线程里调用其它线程1的join方法,当前线程阻塞,但不释放对象锁,直到线程1执行完毕或者millis时间到,当前线程进入可运行状态。j
  4. obj.wait(),当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout)timeout时间到自动唤醒。
  5. obj.notify()唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。

5、syschronized下的一个对象的内存图:

在 Java 虚拟机(HotSpot)中,monitor(监视器/管程对象) 是由 ObjectMonitor 实现的。 ObjectMonitor 中有两个队列,_WaitSet(等待集,也可称等待队列) 和 _EntryList(入口集,也称锁池/锁池队列),以及_Owner 标记。其中_WaitSet 是用于管理等待队列(wait)线程的,_EntryList 是用于管理锁池阻塞线程的(获取不到锁后进入的锁池队列),_Owner 标记用于 记录当前执行线程。线程状态图如下:

         当多线程并发访问 同一个同步代码 时,首先会进入_EntryList,当线程获取锁标记后, monitor 中的_Owner 记录此线程,并在 monitor 中的计数器执行递增计算(+1),代表锁定, 其他线程在_EntryList 中继续阻塞。若执行线程调用 wait 方法,则 monitor 中的计数器执行 赋值为 0 计算,并将_Owner 标记赋值为 null,代表放弃锁,执行线程进如入_WaitSet 中阻塞。 若执行线程调用 notify/notifyAll 方法,_WaitSet 中的线程被唤醒,进入_EntryList 中阻塞,等 待获取锁标记。若执行线程的同步代码执行结束,同样会释放锁标记,monitor 中的_Owner 标记赋值为 null,且计数器赋值为 0 计算。

(线程开始在锁池队列(获取不到锁后进入的队列)中,获取锁标记后monitor计数器+1表示锁定,若调用wait会进入等待队列(wait调用后进入的队列)直到被notify唤醒才进入锁池队列,调用wait时monitor计数器为0,_Owner为null,执行结束也为0 null)

4、对各个线程方法进行分析:

(1)、线程的通知和等待--------------wait、notify、notifyAll、虚假唤醒

1、 wait()函数
当一个线程调用一个共享变量的 wait()方法时,该调用线程会被阻塞挂起, 直到发生下面几件事情之一才返回:

(1 )其他线程调用了该共享对象的 notify()或者 notifyAll() 方法;

(2)其他线程调用了该线程的 interrupt() 方法, 该线程抛出 InterruptedException 异常返回。
(另外需要注意的是,如果调用 wait() 方法的线程没有事先获取该对象的监视器锁,则 调用 wait() 方法时调用线程会抛出 IllegalMonitorStateException 异常。即没获取到锁就去调用wait方法)


那么一个线程如何才能获取一个共享变量的监视器锁(monitor)呢?
( 1 )执行 synchronized 同步代码块时, 使用该共享变量作为参数。
synchronized (共享变量){ //doSomething }
(2 )调用该共享变量的方法,并且该方法使用了 synchronized 修饰。
synchronized void add ( int a, int b) { //doSomething} 

 虚假唤醒问题:   
---------虚假唤醒:线程没有通过notify/notifyAll/中断/等待超时就从挂起状态转换为可运行状态(就绪状态)(即被唤醒,称为虚假唤醒)。

---------解决虚假唤醒:不停的去测试该线程被唤醒的条件是否满足,不满足则继续等待直到真实的唤醒条件符合为止。(即在一个循环内调用wait方法进行防范,当满足被唤醒即真实唤醒的条件则退出该循环)

synchronized (obj) { 
    while (条件不满足时){
        obj.wait();
    }
}

//本线程通过同步块去获取obj对象的监视器锁(monitor),如果本线程不满足唤醒条件时就会进入
  while的循环代码,调用wait方法将其阻塞(释放锁)直至符合唤醒条件为止

//另外需要注意的是,当前线程调用共享变量的 wait() 方法后只会释放当前共享变量上的锁,如果当前线程还持有其他共享变量的锁,则这些锁是不会被释放的。 (即只释放obj对象的监视锁)

其他有参的wait方法:

wait(long timeout)函数:(超过时间没唤醒则会因为超时被唤醒而返回,传负数的会报异常)
该方法相 比 wait() 方法多了一个超时参数,它的不同之处在于,如果一个线程调用共 享对象的该方法挂起后, 没有在指定的 timeout ms 时间 内被其他线程调用该共享变量的 notify() 或者 notifyAll() 方法唤醒,那么该函数还是会因为超时而返回。如果将 timeout 设 置为 0 则和 wait 方法效果一样,因为在 wait 方法内部就是调用了 wait(0)。 需要注意的是, 如果在调用该函数时, 传递了一个负的 timeout 则会抛出 Illega!ArgumentException 异常。

wait(long timeout, int nanos)函数:
在其 内 部调用的是 wait(long timeout)函数,如下代码只有在 nanos>0 时才使参数 timeout 递增 l 。

 

2、notify()方法:等待队列的线程会被随机唤醒一个线程来和其他线程(锁池队列/其他活跃的线程)进行竞争锁资源。

    一个线程调用共享对象的 notify()方法后,会唤醒一个在该共享变量上调用 wait 系列 方法后被挂起的线程。 一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线 程是随机的。
    此外,被唤醒的线程不能马上从 wait 方法返回并继续执行,它必须在获取了共享对 象的监视器锁后才可以返回也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤 醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起 竞争该锁, 只有该线程竞争到了共享变量的监视器锁后才可以继续执行。
    类似 wait 系列方法,只有当前线程获取到了共享变量的监视器锁后,才可以调用共 享变量的 notify() 方法,否则会抛出 IllegalMonitorStateException 异常。(在等待队列中被唤醒的线程会进入锁池队列中与其中的线程/正要去获取该资源的其他线程一起竞争锁资源)

notifyAll()方法:会唤醒所有在该共享变量上由于调用 wait 系列方法而被挂起的线程。即等待队列上的所有线程。

(2)、join方法:插队,

...
public static void main(String[] args){
    ...
    thread1.join();
    thread2.join();
    System.out.println("all child thread over!");
}

主线程首先会在调用thread1.join()后被阻塞,等待thread1执行完毕后,thread2开始阻塞,以此类推,最终会等所有子线程都结束后main函数才会返回。

    又可以叫插队方法,即优先执行插队的线程,其他线程进入堵塞状态(正在运行的不受影响)(join是一个同步方法,底层是用插队对象的wait()让其他线程进入该对象的等待队列中。)直到插队线程运行完再释放锁资源给其他线程竞争。

    方法join()具有使线程排队运行的作用,有些类似于同步的运行效果。join()与synchronized的区别是:join在内部调用wait()方法进行等待,而synchronized关键字使用的是"对象监视器"原理作为同步。

    join方法的主要作用就是同步,它可以使得线程之间的并行执行变为串行执行。在A线程中调用了B线程的join()方法时,表示只有当B线程执行完毕时,A线程才能继续执行。

(3)、sleep ()方法:

sleep()会使线程暂时让出指定时间的执行权,也就是在这期间不参与CPU的调度,但不会释放锁。

Thread 类的静态方法。

(4) yield 方法:礼让cpu

当一个线程调用 yield 方法时, 当前线程会让出 CPU 使用权,然后处于就绪状态,线 程调度器会从线程就绪队列里面获取一个线程优先级最高的线程,当然也有可能会调度到 刚刚让出 CPU 的那个线程来获取 CPU 执行权。

注意:yield()并不会导致线程转到等待/睡眠/阻塞状态。在大多数情况下,yield()将导致线程从运行状态转到可运行状态,但有可能没有效果。

 

总结 : sleep 与 yield 方法的区别在于,当线程调用 sleep 方法时调用线程会被阻塞挂 起指定的时间,在这期间线程调度器不会去调度该线程。 而调用 yield 方法时,线程只是 让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度 时就有可能调度到当前线程执行。

(5)、线程中断机制:

Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行, 而是被中断的线程根据中断状态自行处理。
1、void interrupt()方法: 中断线程

例如,当线程 A 运行时,线程 B 可以调用钱程 A 的 interrupt() 方法来设置线程 A 的中断标志为 true 并立即返回。设置标志仅仅是设 置标志, 线程 A 实际并没有被中断, 它会继续往下执行。 如果线程 A 因为调用了 wait 系列函数、 join 方法或者 sleep 方法而被阻塞挂起,这时候若线程 B 调用线程 A 的 interrupt() 方法,线程 A 会在调用这些方法的地方抛出 InterruptedException 异 常而返回。

2、 boolean isinterrupted() 方法: 检测当前线程是否被中断,如果是返回 true, 否则返回 false。

public boolean is Interrupted() { //传递false,说明不清除中断标志
return isinterrupted ( false) ;

}
3、 boolean interrupted()方法: 检测当前线程是否被中断,如果是返回 true,否则返回 false。
    与 islnterrupted 不同的是,该方法如果发现当前线程被中断,则会清除中断标志,并且该方法是 static 方法, 可以通过 Thread 类直接调用。另外从下面 的代码可以知道,在 interrupted()内部是获取当前调用线程的中断标志而不是调用 interrupted() 方法的实例对象的中断标志。
public static boolean interrupted() { //清除中断标志

return currentThread() .isinterrupted(true); 

}

总结:中断机制是先设置线程的中断标志,如果此时线程是运行状态则仅仅是设置中断标志(对线程的执行没影响);如果是挂起状态(例wait、join、sleep)则会影响,此时线程抛出异常返回。

5、线程上下文切换

    在多线程编程中,线程个数一般都大于CPU个数,而每个CPU同一时刻只能被一个线程使用,为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转的策略,也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当前 线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用 ,这就是上下文切换,从当前线程的上下文切换到了其他线程。 那么就有一个问题,让出 CPU 的线程等下次轮到自己占有CPU时如何知道自己之前运行到哪里了?所以在切换线程上下文时需要保存 当前线程的执行现场, 当再次执行时根据保存的执行现场信息恢复执行现场。


线程上下文切换时机有 : 当前线程的 CPU 时间片使用完处于就绪状态时,当前线程被其他线程中断时。
 

6、线程死锁:

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象, 在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去,如图 1-2 所示。

造成死锁的四个必要条件(同时成立才可以):
1· 互斥条件: 指线程对己经获取到的资源进行排它性使用 , 即该资源同时只由一个线 程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资 源的线程释放该资源。

2· 请求并持有条件 : 指一个线程己经持有了至少一个资源, 但又提出了新的资源请求, 而新资源己被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自 己 己经获取的资源。

3· 不可剥夺条件 : 指线程获取到的资源在自己使用完之前不能被其他线程抢占, 只有 在自己使用完毕后才由 自 己释放该资源。

4· 环路等待条件 : 指在发生死锁时, 必然存在一个线程→资源的环形链, 即线程集合 {TO , TL T2,…, Tn}中 的 TO 正在等待一个 Tl 占用 的资源, Tl 正在等待 T2 占 用的资源,……Tn 正在等待己被 TO 占用的资源。

如何避免死锁:

要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可, 但是学过操作系统的读者应该都知道,目前只有请求并持有和环路等待条件是可以被破坏的。造成死锁的原因其实和申请资源的顺序有很大关系, 使用资源申请的有序性原则就可以避免死锁。

7、守护线程和用户线程

    Java 中的线程分为两类,分别为 daemon 线程(守护线程〉和 user 线程(用户线程)。 在 JVM 启动时会调用 main 函数, main 函数所在的钱程就是一个用户线程,其实在 JVM 内部同时-还启动了好多守护线程, 比如垃圾回收线程。那么守护线程和用户线程有什么区 别呢?区别之一是当最后一个非守护线程结束时, JVM 会正常退出,而不管当前是否有 守护线程,也就是说守护线程是否结束并不影响 JVM 的退出。言外之意,只要有一个用 户线程还没结束, 正常情况下JVM 就不会退出。

总结 : 如果你希望在主线程结束后 JVM 进程马上结束,那么在创建线程时可以将 其设置为守护线程,如果你希望在主线程结束后子线程继续工作,等子线程结束后再让JVM 进程结束,那么就将子线程设置为用户线程。

8、ThreadLocal

这里不对ThreadLocal做介绍,后面会写对于ThreadLocal的安全性、内存泄漏、四种引用、主要用处等做分析

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值