什么,还不了解Java多线程?来,一文帮你搞懂!

471 阅读15分钟
1、并行和并发有什么区别?

并行:单位时间内,多个任务同时执行;

并发:同一时间段,多个任务都在执行。

举例:并发是一个人同时吃三个苹果;并行是三个人同时吃三个苹果。

2、创建线程有几种方式?

共有四种创建方式:

1)继承Thread类,步骤如下:

  • 定义一个类,并使其继承Thread类;
  • 重写run()方法,run()方法的方法体就是线程要执行的任务;
  • 创建线程实例,调用start()方法启动线程。
  • 代码如下:
package thread;
public class MyThread extends Thread {
 
 @Override
 public void run() {
     for (int i = 0; i < 100; i++) {
         System.out.println(getName() + "---" + i);
     }
 }
 
 public static void main(String[] args) {
     for (int i = 0; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "=====" + i);
         if (i == 50) {
             new MyThread().start();
             new MyThread().start();
         }
     }
 }
}

2)实现Runnable接口,步骤如下:

  • 定义Runnable接口的实现类,并重写Run()方法,也是线程的执行体;
  • 创建该实现类的实例对象,并依此实例对象作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象;
  • 调用start()方法启动线程。
  • 代码如下:
package thread;
public class RunnableThreadTest implements Runnable {
 
 @Override
 public void run() {
     for (int i = 0; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "--" + i);
     }
 }
 
 public static void main(String[] args) {
     for (int i = 0; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "==" + i);
         if (i == 20) {
             RunnableThreadTest rtt = new RunnableThreadTest();
             new Thread(rtt, "线程1").start();
             new Thread(rtt, "线程2").start();
         }
     }
 }
}

实现Runnable接口比继承Thread更为灵活,因为可以实现多个接口,但只能继承一个类。

3)实现Callable接口,步骤如下:

  • 创建Callable接口的实现类,并重写call()方法,该call()方法将作为线程执行体,并且有返回值;
  • 创建实现类的实例对象,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值;
  • 使用FutureTask对象作为Thread对象的target创建并启动新线程;
  • 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
  • 代码如下:
package thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
/**
 * 创建Callable接口的实现类
*/
public class CallableThreadTest implements Callable {
 /**
     * 重写run方法,该方法作为线程执行体,并且有返回值
  */
 @Override
 public Integer call() throws Exception {
     int i = 0;
     for (; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "===" + i);
     }
     return i;
 }

 public static void main(String[] args) {
     //创建实现类的实例对象
     CallableThreadTest ctt = new CallableThreadTest();
     //使用FutureTask类来包装Callable对象,FutureTask对象封装了call()方法的返回值
     FutureTask<Integer> ft = new FutureTask<>(ctt);
     for (int i = 0; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "==" + i);
         if (i == 20) {
             //使用FutureTask对象作为Thread对象的target参数创建并启动线程
             new Thread(ft, "有返回值的线程").start();
         }
     }
     try {
         //调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
         System.out.println("子线程的返回值:" + ft.get());
     } catch (InterruptedException e) {
         e.printStackTrace();
     } catch (ExecutionException e) {
         e.printStackTrace();
     }
 }
}
3、Runnable接口和Callable接口有什么区别

Runnable接口中的run()方法的返回值是void,它只是纯粹地去执行run()方法中的代码;

Callable接口中的call()方法是有返回值的,是一个泛型,和FutureFutureTask配合可以用来获取异步执行的结果。

这是很有用的一个特性,因为多线程充满着未知性,某个线程是否执行了?执行了多久?某个线程执行的时候我们期望的数据是否已经赋值完毕?无法得知,我们能做的只是等待这条多线程的任务执行完毕而已。而Callable+Future/FutureTask却可以获取多线程运行的结果,可以在等待时间太长没获取到需要的数据的情况下取消该线程的任务,非常有用的功能。

4、线程有哪些状态?

Java线程具有五中基本状态:

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

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

运行状态(Running):当CPU调度处于就绪状态的线程,该线程才得以真正执行,即进入到运行状态;

注:就绪状态是转化到运行状态的唯一入口,即线程要想进入运行状态执行,首先必须处于就绪状态。

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

  • 等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
  • 同步阻塞:线程获取synchronized同步锁失败(锁可能被其它线程所占用)时,进入同步阻塞状态;
  • 其他阻塞:调用线程的sleep()join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()时状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

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

5、wait()方法和sleep()方法有什么区别?
  • wait()属于Object类的方法,sleep()属于Thread类的方法;

  • wait()释放锁,sleep()不释放锁;(等待是主动行为,会释放锁;睡着了就忘了释放锁,)

  • sleep()常用于使线程暂停执行,而wait()多用于线程间通信;

  • 调用wait(),需要其他线程调用同一对象的notify()notifyAll()后线程才苏醒,或者使用wait(long timeout)超时后线程会自动苏醒;执行sleep(long millis),时间到后线程自动苏醒;

  • 两个方法都可以使线程暂停执行。

6、线程的yield()join()的作用

1)Java线程调度的基本知识:

①在各种各样的线程中,JVM必须实现一个基于优先级的调度程序。这意味着Java程序中的每一个线程都被分配到了一定的优先级,优先级使用定义好的一个正整数表示。优先级可以被开发者改变,但JVM也不会改变优先级。

②优先级的设置很重要,因为JVM和底层操作系统之间的约定是操作系统必须选择高优先级的Java线程运行。所以我们说Java实现了一个基于优先权的调度程序。这意味着当一个有高优先级的线程到来时,无论低优先级的线程是否在运行,都会中断它。但这个约定对于底层操作系统来说并不总是这样,操作系统有时可能会选择运行一个更低优先级的线程。

2)线程的优先级问题

理解线程优先级是多线程学习很重要的一步,尤其是了解yield()方法的工作过程:

①当线程的优先级没有指定时,所有线程都携带普通优先级;

②优先级可以用从1到10的范围指定,10是最高优先级,1是最低优先级,5是普通优先级;

③优先级最高的线程在执行时被给予优先权;

④与在线程池中等待运行机会的线程相比,当前正在运行的线程可能总是拥有更高的优先级;

⑤由调度程序决定哪一个线程被执行;

t.setPriority()用来设定线程的优先级;

⑦在线程start()方法被调用之前,线程的优先级应该被设定;

⑧可以使用常量,如MIN_PRIORITY,MAX_PRIORITY,NORM_PRIORITY来设定优先级。

3)yield()方法

①该方法属于Thread类的方法,yield翻译过来为投降、让步的意思。所以yield()方法被很多人翻译成线程让步,顾名思义,就是说当一个线程使用了这个方法之后,它就会把自己CPU执行权让出来,让自己或者其它的线程运行;

②该方法的作用就是:使当前线程从运行状态变为就绪状态。CPU会从处于就绪状态的线程选择执行,也就是说,刚刚的那个线程还是有可能会被再次执行到的,并不是说一定会执行其他线程而该线程不会执行。

4)join()方法

join()方法可以把指定的线程加入到当前线程,可以将两个交替执行的线程转换为按顺序执行。比如在线程B中调用了线程A的join()方法,直到线程A执行完毕后,才会继续执行线程B。

7、线程的notify()nofityAll()有什么区别?

1)两个概念:锁池和等待池

①锁池:假设线程A已经拥有了某个对象(不是类)的锁,这时其它线程想要调用这个对象的某个synchronized方法或代码块,由于这些线程在进入对象的synchronized方法或代码块之前必须先获得该对象的锁,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。

②等待池:假设线程A调用某个对象的wait()方法,这时线程A就会释放该对象的锁,进入到该对象的等待池中。

2)notify()notifyAll()的区别

①如果线程调用了对象的wait()方法,那么该线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。

②当线程调用了对象的notifyAll()方法或notify()方法后,被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用notify()只有一个线程会由等待池进入锁池,而notifyAll()会将该对象等待池内的所有线程移动到锁池中,进行锁竞争;

说明:notifyAll()用于唤醒所有wait线程;notify()只能随机唤醒一个wait线程。

③优先级高的线程竞争到对象锁的概率大。假若某线程没有竞争到该对象锁,则它还会留在锁池中。只有线程再次调用wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized方法或代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

综上,所谓唤醒线程,另一种解释可以说是将线程由等待池移动到锁池,notifyAll()会将全部线程由等待池移到锁池参与锁的竞争,竞争成功继续执行,竞争失败则留在锁池等待锁被释放后再次参与竞争,而notify()只会唤醒一个线程。

3)为什么notify()可能会导致死锁,而notifyAll()则不会

notify()只唤醒一个正在等待的线程,当该线程执行完会释放该对象的锁,如果没有再次执行notify()方法,则其它正在等待的线程会一直处于等待状态,不会被唤醒进入该对象的锁池,就会发生死锁。但notifyAll()则不会出现死锁问题。

8、创建线程池有哪几种方式?

Java通过Executors提供四种创建线程池的方式,分别为:

newCachedThreadPool:创建一个可缓存的线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收的线程,则新建线程;

newFixedThreadPool :创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待;

newScheduledThreadPool: 创建一个定长线程池,支持定时及周期性任务执行;

newSingleThreadExecutor :创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO,LIFO,,优先级)执行。

9、生成线程池方法的入参大概有哪几个核心参数?每个参数代表什么意思?

线程池部分的内容具体参考这篇文章

10、volatile关键字的作用

作用:

①保证变量可见性,被volatile修饰的变量,如果值发生改变,其他线程立马可见,避免出现脏读;

②禁止指令重排序,指令重排序发生在多线程中,指的是代码的执行顺序可能会经过编译器的优化发生改变。比如懒汉式单例中的实例对象就需要volatile修饰,否则可能出现实例化两个对象。

注意:volatile关键字不能保证变量的原子性。

  • 为什么会出现脏读?

Java内存模型规定变量都是存在主存当中,每个线程都有自己的工作内存。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作,并且每个线程不能访问其他线程的工作内存。这就有可能出现A线程修改了变量的值,而B线程还不知道,造成B线程还在使用A线程修改之前的变量值,这时B线程使用到的就是脏数据。

②为了解决这一问题,就需要把变量声明为volatile,表示告诉JVM,这个变量是不稳定的,每次使用它都要从主存中进行读取。主存是共享区域,操作主存,其他线程都可见。

11、synchronized关键字的作用

作用:synchronized表示同步的意思,被synchronized修饰过的方法或代码块在同一时刻只能有一个线程在执行。

用法:

①修饰非静态方法:使用的锁是this;

public synchronized void method() {}

②修饰代码块:使用的锁由自己决定,只要是对象就可以;

Object obj = new Object()
    
synchronized (obj){
//......
}

③修饰静态方法:使用的锁是该方法所属类的字节码文件。

public static synchronized void sale() {}
12、synchronized锁升级的过程

首先来了解相关锁的概念:

自旋锁(CAS): 让不满足条件的线程等待一会儿看能不能获得锁,通过占用CPU的时间来避免线程切换带来的开销。自旋等待的时间或次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。在JDK1.6之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么JVM就会认为这次自旋很有可能会再次成功,进而将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

偏向锁: 大多数情况下,锁总是由同一个线程多次获得。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,偏向锁是一个可重入的锁。如果锁对象头的Mark Word里存储着指向当前线程的偏向锁,无需重新进行CAS操作来加锁和解锁。当有其他线程尝试竞争偏向锁时,持有偏向锁的线程(不处于活动状态)才会释放锁。偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定进而升级为轻量级锁。

重量级锁: 通过对象内部的监视器(monitor)实现,monitor本质是依赖底层操作系统的MutexLock实现的,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。线程竞争不使用自旋,不会消耗CPU。但是线程会进入阻塞等待直到被其他线程唤醒,响应时间缓慢。

轻量级锁: 减少无实际竞争情况下,使用重量级锁产生的性能消耗。JVM会在当前线程的栈桢中创建用于存储锁记录的空间LockRecord,将对象头中的Mark Word复制到LockRecord中并将LockRecord中的Owner指针指向锁对象。然后线程会尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,成功则当前线程获取到锁,失败则表示其他线程竞争锁,当前线程则尝试使用自旋的方式获取锁。自旋获取锁失败则锁膨胀升级为重量级锁。

13、什么是死锁?

线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,无法正常执行下去。

如下图所示,线程A持有资源2,线程B持有资源1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。