建议收藏对线面试官:多线程硬核50问

Posted 敲代码的程序狗

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了建议收藏对线面试官:多线程硬核50问相关的知识,希望对你有一定的参考价值。

前言

金九银十快要来了,整理了50道多线程并发面试题,大家可以点赞、收藏起来,慢慢品!~

1、为什么要使用多线程

选择多线程的原因,就是因为快。举个例子:

如果要把1000块砖搬到楼顶,假设到楼顶有几个电梯,你觉得用一个电梯搬运快,还是同时用几个电梯同时搬运快呢?这个电梯就可以理解为线程。

所以,我们使用多线程就是因为:在正确的场景下,设置恰当数目的线程,可以用来程提高序的运行速率。更专业点讲,就是充分地利用CPU和I/O的利用率,提升程序运行速率。

当然,有利就有弊,多线程场景下,我们要保证线程安全,就需要考虑加锁。加锁如果不恰当,就很很耗性能。

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

Java中创建线程主要有以下这几种方式:

  • 定义Thread类的子类,并重写该类的run方法
  • 定义Runnable接口的实现类,并重写该接口的run()方法
  • 定义Callable接口的实现类,并重写该接口的call()方法,一般配合Future使用
  • 线程池的方式

2.1 定义Thread类的子类,并重写该类的run方法

public class ThreadTest 

    public static void main(String[] args) 
        Thread thread = new MyThread();
        thread.start();
    


class MyThread extends Thread 
    @Override
    public void run() 
        System.out.println("111");
    

2.2 定义Runnable接口的实现类,并重写该接口的run()方法

public class ThreadTest 

    public static void main(String[] args) 
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
    


class MyRunnable implements Runnable 
    @Override
    public void run() 
        System.out.println("111");
    

//运行结果:

2.3 定义Callable接口的实现类,并重写该接口的call()方法

如果想要执行的线程有返回,可以使用Callable。

public class ThreadTest 
    public static void main(String[] args) throws ExecutionException, InterruptedException 
        MyThreadCallable mc = new MyThreadCallable();
        FutureTask<Integer> ft = new FutureTask<>(mc);
        Thread thread = new Thread(ft);
        thread.start();
        System.out.println(ft.get());
    


class MyThreadCallable implements Callable 
    @Override
    public String call()throws Exception 
        return "111";
    

//运行结果:
111

2.4 线程池的方式

日常开发中,我们一般都是用线程池的方式执行异步任务。

public class ThreadTest 

    public static void main(String[] args) throws Exception 

        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
                TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20), new CustomizableThreadFactory("Tianluo-Thread-pool"));
        executorOne.execute(() -> 
            System.out.println("111");
        );

        //关闭线程池
        executorOne.shutdown();
    

3. start()方法和run()方法的区别

其实start和run的主要区别如下:

  • start方法可以启动一个新线程,run方法只是类的一个普通方法而已,如果直接调用run方法,程序中依然只有主线程这一个线程。
  • start方法实现了多线程,而run方法没有实现多线程。
  • start不能被重复调用,而run方法可以。
  • start方法中的run代码可以不执行完,就继续执行下面的代码,也就是说进行了线程切换。然而,如果直接调用run方法,就必须等待其代码全部执行完才能继续执行下面的代码。

大家可以结合代码例子来看看哈~

public class ThreadTest 

    public static void main(String[] args)
        Thread t=new Thread()
            public void run()
                pong();
            
        ;
        t.start();
        t.run();
        t.run();
        System.out.println("好的,马上去111"+ Thread.currentThread().getName());
    

    static void pong()
        System.out.println("111"+ Thread.currentThread().getName());
    


//输出
111main
111main
好的,马上去111main
111Thread-0

4. 线程和进程的区别

  • 进程是运行中的应用程序,线程是进程的内部的一个执行序列
  • 进程是资源分配的最小单位,线程是CPU调度的最小单位。
  • 一个进程可以有多个线程。线程又叫做轻量级进程,多个线程共享进程的资源
  • 进程间切换代价大,线程间切换代价小
  • 进程拥有资源多,线程拥有资源少地址
  • 进程是存在地址空间的,而线程本身无地址空间,线程的地址空间是包含在进程中的

举个例子:

你打开QQ,开了一个进程;打开了迅雷,也开了一个进程。

在QQ的这个进程里,传输文字开一个线程、传输语音开了一个线程、弹出对话框又开了一个线程。

所以运行某个软件,相当于开了一个进程。在这个软件运行的过程里(在这个进程里),多个工作支撑的完成QQ的运行,那么这“多个工作”分别有一个线程。

所以一个进程管着多个线程。

通俗地讲:“进程是爹妈,管着众多的线程儿子”…

5. 说一下 Runnable和 Callable有什么区别?

  • Runnable接口中的run()方法没有返回值,是void类型,它做的事情只是纯粹地去执行run()方法中的代码而已;
  • Callable接口中的call()方法是有返回值的,是一个泛型。它一般配合Future、FutureTask一起使用,用来获取异步执行的结果。
  • Callable接口call()方法允许抛出异常;而Runnable接口run()方法不能继续上抛异常;

大家可以看下它俩的API:

 @FunctionalInterface
public interface Callable<V> 
    /**
     * 支持泛型V,有返回值,允许抛出异常
     */
    V call() throws Exception;


@FunctionalInterface
public interface Runnable 
    /**
     *  没有返回值,不能继续上抛异常
     */
    public abstract void run();

为了方便大家理解,写了一个demo,小伙伴们可以看看哈:

/*
 *  @Author 111
 *  @date 2022-07-11
 */
public class CallableRunnableTest 

    public static void main(String[] args) 
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        Callable<String> callable =new Callable<String>() 
            @Override
            public String call() throws Exception 
                return "你好,callable,111";
            
        ;

        //支持泛型
        Future<String> futureCallable = executorService.submit(callable);

        try 
            System.out.println("获取callable的返回结果:"+futureCallable.get());
         catch (InterruptedException e) 
            e.printStackTrace();
         catch (ExecutionException e) 
            e.printStackTrace();
        

        Runnable runnable = new Runnable() 
            @Override
            public void run() 
                System.out.println("你好呀,runnable,111");
            
        ;

        Future<?> futureRunnable = executorService.submit(runnable);
        try 
            System.out.println("获取runnable的返回结果:"+futureRunnable.get());
         catch (InterruptedException e) 
            e.printStackTrace();
         catch (ExecutionException e) 
            e.printStackTrace();
        
        executorService.shutdown();

    

//运行结果
获取callable的返回结果:你好,callable,111
你好呀,runnable,111
获取runnable的返回结果:null

6. 聊聊volatile作用,原理

volatile关键字是Java虚拟机提供的的最轻量级的同步机制。它作为一个修饰符,用来修饰变量。它保证变量对所有线程可见性,禁止指令重排,但是不保证原子性

我们先来一起回忆下java内存模型(jmm):

  • Java虚拟机规范试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
  • Java内存模型规定所有的变量都是存在主内存当中,每个线程都有自己的工作内存。这里的变量包括实例变量和静态变量,但是不包括局部变量,因为局部变量是线程私有的。
  • 线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存。并且每个线程不能访问其他线程的工作内存。

volatile变量,保证新值能立即同步回主内存,以及每次使用前立即从主内存刷新,所以我们说volatile保证了多线程操作变量的可见性。

volatile保证可见性和禁止指令重排,都跟内存屏障有关。我们来看一段volatile使用的demo代码:

/**
 * 111
public class Singleton   
   private volatile static Singleton instance;  
   private Singleton ()  
   public static Singleton getInstance()   
   if (instance == null)   
       synchronized (Singleton.class)   
       if (instance == null)   
           instance = new Singleton();  
         
         
     
   return instance;  
     
  

编译后,对比有volatile关键字和没有volatile关键字时所生成的汇编代码,发现有volatile关键字修饰时,会多出一个lock addl $0x0,(%esp),即多出一个lock前缀指令,lock指令相当于一个内存屏障

lock指令相当于一个内存屏障,它保证以下这几点:

  1. 重排序时不能把后面的指令重排序到内存屏障之前的位置
  2. 将本处理器的缓存写入内存
  3. 如果是写入动作,会导致其他处理器中对应的缓存无效。

第2点和第3点就是保证volatile保证可见性的体现嘛,第1点就是禁止指令重排的体现

内存屏障四大分类:(Load 代表读取指令,Store代表写入指令)

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

有些小伙伴,可能对这个还是有点疑惑,内存屏障这玩意太抽象了。我们照着代码看下吧:

内存屏障保证前面的指令先执行,所以这就保证了禁止了指令重排啦,同时内存屏障保证缓存写入内存和其他处理器缓存失效,这也就保证了可见性,哈哈有关于volatile的底层实现,我们就讨论到这哈

7. 说说并发与并行的区别?

并发和并行最开始都是操作系统中的概念,表示的是CPU执行多个任务的方式。

  • 顺序:上一个开始执行的任务完成后,当前任务才能开始执行
  • 并发:无论上一个开始执行的任务是否完成,当前任务都可以开始执行

(即 A B 顺序执行的话,A 一定会比 B 先完成,而并发执行则不一定。)

  • 串行:有一个任务执行单元,从物理上就只能一个任务、一个任务地执行
  • 并行:有多个任务执行单元,从物理上就可以多个任务一起执行

(即在任意时间点上,串行执行时必然只有一个任务在执行,而并行则不一定。)

知乎有个很有意思的回答,大家可以看下:

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。

你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。

你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

并发的关键是你有处理多个任务的能力,不一定要同时。并行的关键是你有同时处理多个任务的能力。所以我认为它们最关键的点就是:是否是同时。

来源:知乎

8.synchronized 的实现原理以及锁优化?

synchronized是Java中的关键字,是一种同步锁。synchronized关键字可以作用于方法或者代码块。

一般面试时。可以这么回答:

8.1 monitorenter、monitorexit、ACC_SYNCHRONIZED

如果synchronized作用于代码块,反编译可以看到两个指令:monitorenter、monitorexit,JVM使用monitorenter和monitorexit两个指令实现同步;如果作用synchronized作用于方法,反编译可以看到ACCSYNCHRONIZED标记,JVM通过在方法访问标识符(flags)中加入ACCSYNCHRONIZED来实现同步功能。

  • 同步代码块是通过monitorenter和monitorexit来实现,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。
  • 同步方法是通过中设置ACCSYNCHRONIZED标志来实现,当线程执行有ACCSYNCHRONI标志的方法,需要获得monitor锁。每个对象都与一个monitor相关联,线程可以占有或者释放monitor。

8.2 monitor监视器

monitor是什么呢?操作系统的管程(monitors)是概念原理,ObjectMonitor是它的原理实现。

在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下:

 ObjectMonitor() 
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;  // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  

ObjectMonitor中几个关键字段的含义如图所示:

8.3 Java Monitor 的工作机理

  • 想要获取monitor的线程,首先会进入_EntryList队列。
  • 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
  • 如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,即将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
  • 如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
  • 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。

8.4 对象与monitor关联

  • 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对象填充(Padding)
  • 对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)

Mark Word 是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。

重量级锁,指向互斥量的指针。其实synchronized是重量级锁,也就是说Synchronized的对象锁,Mark Word锁标识位为10,其中指针指向的是Monitor对象的起始地址。

9. 线程有哪些状态?

线程有6个状态,分别是:New, Runnable, Blocked, Waiting, Timed_Waiting, Terminated。

转换关系图如下:

  • New:线程对象创建之后、但还没有调用start()方法,就是这个状态。
/**
 * 111
 */
public class ThreadTest 

    public static void main(String[] args) 
        Thread thread = new Thread();
        System.out.println(thread.getState());
    


//运行结果:
NEW
  • Runnable:它包括就绪(ready)和运行中(running)两种状态。如果调用start方法,线程就会进入Runnable状态。它表示我这个线程可以被执行啦(此时相当于ready状态),如果这个线程被调度器分配了CPU时间,那么就可以被执行(此时处于running状态)。
public class ThreadTest 

    public static void main(String[] args) 
        Thread thread = new Thread();
        thread.start();
        System.out.println(thread.getState());
    

//运行结果:
RUNNABLE
  • Blocked:阻塞的(被同步锁或者IO锁阻塞)。表示线程阻塞于锁,线程阻塞在进入synchronized关键字修饰的方法或代码块(等待获取锁)时的状态。比如前面有一个临界区的代码需要执行,那么线程就需要等待,它就会进入这个状态。它一般是从RUNNABLE状态转化过来的。如果线程获取到锁,它将变成RUNNABLE状态
Thread t = new Thread(new Runnable 
    void run() 
        synchronized (lock)  // 阻塞于这里,变为Blocked状态
            // dothings
         
    
);
t.getState(); //新建之前,还没开始调用start方法,处于New状态

t.start(); //调用start方法,就会进入Runnable状态
  • WAITING : 永久等待状态,进入该状态的线程需要等待其他线程做出一些特定动作(比如通知)。处于该状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。一般Object.wait。
Thread t = new Thread(new Runnable 
    void run() 
        synchronized (lock)  // Blocked
            // dothings
            while (!condition) 
                lock.wait(); // into Waiting
            
         
    
);
t.getState(); // New

t.start(); // Runnable
  • TIMED_WATING: 等待指定的时间重新被唤醒的状态。有一个计时器在里面计算的,最常见就是使用Thread.sleep方法触发,触发后,线程就进入了Timed_waiting状态,随后会由计时器触发,再进入Runnable状态。
Thread t = new Thread(new Runnable 
    void run() 
        Thread.sleep(1000); // Timed_waiting
    
);
t.getState(); // New
t.start(); // Runnable
  • 终止(TERMINATED):表示该线程已经执行完成。

再来看个代码demo吧:

/**
 * 111
 */
public class ThreadTest 

    private static Object object = new Object();

    public static void main(String[] args) throws Exception 

        Thread thread = new Thread(new Runnable() 
            @Override
            public void run() 
                try 
                    for(int i = 0; i< 1000; i++)
                        System.out.print("");
                    
                    Thread.sleep(500);
                    synchronized (object)
                        object.wait();
                    
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
            
        );

        Thread thread1 = new Thread(new Runnable() 
            @Override
            public void run() 
                try 
                    synchronized (object)
                        Thread.sleep(1000);
                    
                    Thread.sleep(1000);
                    synchronized (object)
                        object.notify();
                    
                 catch (InterruptedException e) 
                    e.printStackTrace();
                
            
        );

        System.out.println("1"+thread.getState());
        thread.start();
        thread1.start();
        System.out.println("2"+thread.getState());
        while (thread.isAlive())
            System.out.println("---"+thread.getState());
            Thread.sleep(100);
        
        System.out.println("3"+thread.getState());
    


运行结果:
1NEW
2RUNNABLE
---RUNNABLE
---TIMED_WAITING
---TIMED_WAITING
---TIMED_WAITING
---TIMED_WAITING
---BLOCKED
---BLOCKED
---BLOCKED
---BLOCKED
---BLOCKED
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING

10. synchronized和ReentrantLock的区别?

  • Synchronized是依赖于JVM实现的,而ReenTrantLock是API实现的。
  • 在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者性能就差不多了。
  • Synchronized的使用比较方便简洁,它由编译器去保证锁的加锁和释放。而ReenTrantLock需要手工声明来加锁和释放锁,最好在finally中声明释放锁。
  • ReentrantLock可以指定是公平锁还是⾮公平锁。⽽synchronized只能是⾮公平锁。
  • ReentrantLock可响应中断、可轮回,而Synchronized是不可以响应中断的

以上是关于建议收藏对线面试官:多线程硬核50问的主要内容,如果未能解决你的问题,请参考以下文章

上海某大公司:你是了解Synchronize锁的,对吧?

Java面试题⭐多线程篇⭐(万字总结,带答案,面试官问烂,跳槽必备,建议收藏)

Java面试题⭐多线程篇⭐(万字总结,带答案,面试官问烂,跳槽必备,建议收藏)

Java面试题⭐多线程篇⭐(万字总结,带答案,面试官问烂,跳槽必备,建议收藏)

Java面试题⭐多线程篇⭐(万字总结,带答案,面试官问烂,跳槽必备,建议收藏)

跟Java面试官对线的一天!唬住就要50K,唬不住就要5K