Java基础-多线程

一、是什么

  从硬件或软件上实现多个线程并发执行的技术。

  针对Java来说,Java编写程序都运行在在Java虚拟机(JVM)中,在JVM的内部,程序的多任务是通过线程来实现的。每用java命令启动一个java应用程序,就会启动一个JVM进程。在同一个JVM进程中,有且只有一个进程,就是它自己。在这个JVM环境中,所有程序代码的运行都是以线程来运行。

  在Java程序中,JVM负责线程的调度。线程调度是值按照特定的机制为多个线程分配CPU的使用权。调度的模式有两种:分时调度和抢占式调度。分时调度是所有线程轮流获得CPU使用权,并平均分配每个线程占用CPU的时间;抢占式调度是根据线程的优先级别来获取CPU的使用权。JVM的线程调度模式采用了抢占式模式。

  学习多线程需要了解的几个概念:

  进程:每个进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销,一个进程包含1--n个线程。(进程是资源分配的最小单位)

  线程:同一类线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器(PC),线程切换开销小。(线程是cpu调度的最小单位)

  并行:多个cpu实例或者多台机器同时执行一段处理逻辑,是真正的同时。

  并发:通过cpu调度算法,让用户看上去同时执行,实际上从cpu操作层面不是真正的同时。并发往往在场景中有公用的资源,那么针对这个公用的资源往往产生瓶颈,我们会用TPS或者QPS来反应这个系统的处理能力。

  线程安全:在并发的情况之下,该代码经过多线程使用,线程的调度顺序不影响任何结果。

  线程安全分级别: 
  (1)不可变 
  像String、Integer、Long这些,都是final类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个,因此这些不可变对象不需要任何同步手段就可以直接在多线程环境下使用 
  (2)绝对线程安全 
  不管运行时环境如何,调用者都不需要额外的同步措施。要做到这一点通常需要付出许多额外的代价,Java中标注自己是线程安全的类,实际上绝大多数都不是线程安全的,不过绝对线程安全的类,Java中也有,比方说CopyOnWriteArrayList、CopyOnWriteArraySet 
  (3)相对线程安全 
  相对线程安全也就是我们通常意义上所说的线程安全,像Vector这种,add、remove方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个Vector、有个线程同时在add这个Vector,99%的情况下都会出现ConcurrentModificationException,也就是fail-fast机制。 
  (4)线程非安全 
  ArrayList、LinkedList、HashMap等都是线程非安全的类

  同步:通过人为的控制和调度,保证共享资源的多线程访问成为线程安全,来保证结果的准确。

  线程的生命周期

  1、新建状态

  用new关键字和Thread类或其子类建立一个线程对象后,该线程对象就处于新生状态。处于新生状态的线程有自己的内存空间,通过调用start方法进入就绪状态(runnable)。

  2、就绪状态

  处于就绪状态的线程已经具备了运行条件,但还没有分配到CPU,处于线程就绪队列(尽管是采用队列形式,事实上,把它称为可运行池而不是可运行队列。因为cpu的调度不一定是按照先进先出的顺序来调度的),等待系统为其分配CPU。

  小技巧:如果希望子线程调用start()方法后立即执行,可以使用Thread.sleep()方式使主线程睡眠一伙儿,转去执行子线程。

  3、运行状态

  处于就绪状态的线程,如果获得了cpu的调度,就会从就绪状态变为运行状态,执行run()方法中的任务。如果该线程失去了cpu资源,就会又从运行状态变为就绪状态。重新等待系统分配资源。也可以对在运行状态的线程调用yield()方法,它就会让出cpu资源,再次变为就绪状态。  

  4、阻塞状态

  处于运行状态的线程在某些情况下,如执行了sleep(睡眠)方法,或等待I/O设备等资源,将让出CPU并暂时停止自己的运行,进入阻塞状态。 在阻塞状态的线程不能进入就绪队列。只有当引起阻塞的原因消除时,如睡眠时间已到,或等待的I/O设备空闲下来,线程便转入就绪状态,重新到就绪队列中排队等待,被系统选中后从原来停止的位置开始继续运行。

注: 当发生如下情况是,线程会从运行状态变为阻塞状态:

     ①、线程调用sleep方法主动放弃所占用的系统资源

     ②、线程调用一个阻塞式IO方法,在该方法返回之前,该线程被阻塞

     ③、线程试图获得一个同步监视器,但更改同步监视器正被其他线程所持有

     ④、线程在等待某个通知(notify)

     ⑤、程序调用了线程的suspend方法将线程挂起。不过该方法容易导致死锁,所以程序应该尽量避免使用该方法。

  5、死亡状态

  当线程的run()方法执行完,或者被强制性地终止,例如出现异常,或者调用了stop()、desyory()方法等等,就会从运行状态转变为死亡状态。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦死亡,就不能复生。

二、有什么用

  多线程相较单线程的优势:

  (一)多线程发挥硬件多核优势

  如果是单线程的程序,那么在双核CPU上就浪费了50%,在4核CPU上就浪费了75%。多核CPU上的多线程是真正的多线程,它能让你的多段逻辑同时工作,多线程,可以真正发挥出多核CPU的优势来,达到充分利用CPU的目的。

  (二)防止阻塞

  从程序运行效率的角度来看,单核CPU不但不会发挥出多线程的优势,反而会因为在单核CPU上运行多线程导致线程上下文的切换,而降低程序整体的效率。但如果单核CPU使用单线程,那么只要这个线程阻塞(BIO),整个程序在数据返回之前就停止运行。多线程可以防止这个问题,多条线程同时运行,哪怕一条线程的代码执行读取数据阻塞,也不会影响其它任务的执行。 

  (三)解决单线程无法满足的一些场景

  最常见的生产者-消费者模型。(顺便多一嘴,生产者-消费者模型的作用:(1)通过平衡生产者的生产能力和消费者的消费能力来提升整个系统的运行效率,这是生产者消费者模型最重要的作用 ;(2)解耦。解耦意味着生产者和消费者之间的联系少,联系越少越可以独自发展而不需要收到相互的制约。)

  

  多线程相较多进程的优势:

  (一)进程之间不能共享数据,同一进程中的多线程可以。

  (二)系统创建进程需要为该进程重新分配系统资源,故创建线程代价比较小。

  (三)Java提供了语言级别的多线程编程支持,简化多线程开发难度

三、怎么用

  多线程实现方式:

  3.1、继承Thread类

  • 定义一个继承Thread类的子类,并重写该类的run()方法;
  • 创建Thread子类的实例,即创建了线程对象;
  • 调用该线程对象的start()方法启动线程。
public class ThreadDemo extends Thread {

    @Override
    public void run() {
        //为所欲为之为所欲为
    }

    public static void main(String[] args) {
        ThreadDemo td = new ThreadDemo();
        td.start(); // 启动线程
    }

}

  3.1、实现Runnable接口

  使用接口的方式可以让我们的程序降低耦合度(面向接口编程了解一下)。Runnable就是一个线程任务,线程任务(Runnable)和线程的控制(Thread)分离,这也就是上面所说的解耦。

  • 定义Runnable接口的实现类,并重写该接口的run()方法;
  • 创建Runnable实现类的实例,并以此实例作为Thread的target对象,即该Thread对象才是真正的线程对象。
public class ThreadTarget implements Runnable {

    @Override
    public void run() {
       //依旧为所欲为之为所欲为
    }

}

public static void main(String[] args) {

        ThreadTarget tt = new ThreadTarget(); // 实例化线程任务类
        Thread t = new Thread(tt); // 创建线程对象,并将线程任务类作为构造方法参数传入
        t.start(); // 启动线程
    }

  3.3、通过Callable和Future创建线程

  继承Thread类或者实现Runnable接口有两个问题,第一个是无法抛出更多的异常,第二个是线程执行完毕之后并无法获得线程的返回值。为了解决这两个问题可以用第三种方式创建多线程。

  • 创建一个类实现Callable接口,实现call方法,该call()方法将作为线程执行体,并且有返回值。这个接口类似于Runnable接口,但比Runnable接口更加强大,增加了异常和返回值。
  • 创建一个FutureTask,指定Callable对象,做为线程任务,该FutureTask对象封装了该Callable对象的call()方法的返回值。
  • 使用FutureTask对象作为Thread对象的target创建并启动新线程。
  • 启动线程,调用FutureTask对象的get()方法来获得子线程执行结束后的返回值其中
public class CallableTest {

    public static void main(String[] args) throws Exception {
        Callable<Integer> call = new Callable<Integer>() {

            @Override
            public Integer call() throws Exception {
                //还是为所欲为之为所欲为
                return 1;
            }
        };

        FutureTask<Integer> task = new FutureTask<>(call);
        Thread t =  new Thread(task);

        t.start();
        System.out.println("do other thing .. ");
        System.out.println("拿到线程的执行结果 : " + task.get());
    }

}

  线程管理:

  1、线程睡眠——sleep

  如果我们需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用Thread的sleep方法。sleep是静态方法,最好不要用Thread的实例对象调用它,因为它睡眠的始终是当前正在运行的线程,而不是调用它的线程对象,它只对正在运行状态的线程对象有效。

  2、线程让步——yield

  Thread类提供的一个静态的方法,它也可以让当前正在执行的线程暂停,让出cpu资源给其他的线程。但是和sleep()方法不同的是,它不会进入到阻塞状态,而是进入到就绪状态。yield()方法只是让当前线程暂停一下,重新进入就绪的线程池中,让系统的线程调度器重新调度器重新调度一次。

  关于sleep()方法和yield()方的区别如下:

  ①、sleep方法暂停当前线程后,会进入阻塞状态,只有当睡眠时间到了,才会转入就绪状态。而yield方法调用后 ,是直接进入就绪状态,所以有可能刚进入就绪状态,又被调度到运行状态。

  ②、sleep方法声明抛出了InterruptedException,所以调用sleep方法的时候要捕获该异常,或者显示声明抛出该异常。而yield方法则没有声明抛出任务异常。

  ③、sleep方法比yield方法有更好的可移植性,通常不要依靠yield方法来控制并发线程的执行。

  3、线程合并——join

  非静态方法,将几个并行线程的线程合并为一个单线程执行,应用场景是当一个线程必须等待另一个线程执行完毕才能执行时。

  4、设置线程的优先级

  每个线程执行时都有一个优先级的属性,优先级高的线程可以获得较多的执行机会,而优先级低的线程则获得较少的执行机会。与线程休眠类似,线程的优先级仍然无法保障线程的执行次序。只不过,优先级高的线程获取CPU资源的概率较大,优先级低的也并非没机会执行。

  每个线程默认的优先级都与创建它的父线程具有相同的优先级,在默认情况下,main线程具有普通优先级。

  小技巧:不同的操作系统的优先级并不相同,而且也不能很好的和Java的10个优先级别对应。所以我们应该使用MAX_PRIORITY、MIN_PRIORITY和NORM_PRIORITY三个静态常量来设定优先级,这样才能保证程序最好的可移植性。

四、深入研究方向

  4.1、后台(守护)线程

  4.2、正确结束线程

  4.3、线程同步

  4.4、线程通信

  4.5、线程池

  4.6、ThreadLocal

五、面试点

  5.1、start()方法和run()方法的区别

  5.2、Runnable接口和Callable接口的区别

  5.3、CyclicBarrier和CountDownLatch的区别

  5.4、synchronized和ReentrantLock的区别

  5.5、ConcurrentHashMap的并发度是什么

  5.6、如果你提交任务时,线程池队列已满,这时会发生什么

  如果你使用的LinkedBlockingQueue,也就是无界队列的话,没关系,继续添加任务到阻塞队列中等待执行,因为LinkedBlockingQueue可以近乎认为是一个无穷大的队列,可以无限存放任务;如果你使用的是有界队列比方说ArrayBlockingQueue的话,任务首先会被添加到ArrayBlockingQueue中,ArrayBlockingQueue满了,则会使用拒绝策略RejectedExecutionHandler处理满了的任务,默认是AbortPolicy。

  5.7、Thread.sleep(0)的作用是什么

  由于Java采用抢占式的线程调度算法,因此可能会出现某条线程常常获取到CPU控制权的情况,为了让某些优先级比较低的线程也能获取到CPU控制权,可以使用Thread.sleep(0)手动触发一次操作系统分配时间片的操作,这也是平衡CPU控制权的一种操作。

  5.8、线程类的构造方法、静态块是被哪个线程调用的

  线程类的构造方法、静态块是被new这个线程类所在的线程所调用的,而run方法里面的代码才是被线程自身所调用的。 

  5.9、同步方法和同步块,哪个是更好的选择

  同步块,这意味着同步块之外的代码是异步执行的,这比同步整个方法更提升代码的效率。请知道一条原则:同步的范围越少越好。 
借着这一条,我额外提一点,虽说同步的范围越少越好,但是在Java虚拟机中还是存在着一种叫做锁粗化的优化方法,这种方法就是把同步范围变大。这是有用的,比方说StringBuffer,它是一个线程安全的类,自然最常用的append()方法是一个同步方法,我们写代码的时候会反复append字符串,这意味着要进行反复的加锁->解锁,这对性能不利,因为这意味着Java虚拟机在这条线程上要反复地在内核态和用户态之间进行切换,因此Java虚拟机会将多次append方法调用的代码进行一个锁粗化的操作,将多次的append的操作扩展到append方法的头尾,变成一个大的同步块,这样就减少了加锁–>解锁的次数,有效地提升了代码执行的效率。

  5.10、高并发、任务执行时间短的业务怎样使用线程池?并发不高、任务执行时间长的业务怎样使用线程池?并发高、业务执行时间长的业务怎样使用线程池?

  (1)高并发、任务执行时间短的业务,线程池线程数可以设置为CPU核数+1,减少线程上下文的切换 

  (2)并发不高、任务执行时间长的业务要区分开看: 

  a)假如是业务时间长集中在IO操作上,也就是IO密集型的任务,因为IO操作并不占用CPU,所以不要让所有的CPU闲下来,可以加大线程池中的线程数目,让CPU处理更多的业务 
  b)假如是业务时间长集中在计算操作上,也就是计算密集型任务,这个就没办法了,和(1)一样吧,线程池中的线程数设置得少一些,减少线程上下文的切换 
  (3)并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)。最后,业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件对任务进行拆分和解耦。

猜你喜欢

转载自www.cnblogs.com/qhj348770376/p/9164494.html