Java面试准备之并发基础

hresh 540 0

Java面试准备之并发基础

什么是线程和进程?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是,同进程下的线程共享进程的方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一 个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

进程和线程的区别是什么?

  • 进程是运行中的程序,线程是进程的内部的一个执行序列;
  • 进程是资源分配的单元,线程是执行行单元;
  • 进程间切换代价大,线程间切换代价小;
  • 进程拥有资源多,线程拥有资源少;
  • 地址空间和其它资源:进程间相互独立,同一进程的各线程间共享。某进程内的线程在其它进程不可见;
  • 通信:进程间通信 IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性;
  • 在多线程 OS 中,进程不是一个可执行的实体;

请简要描述线程与进程的关系

从 JVM 角度说进程和线程之间的关系

JDK1.6:

Java面试准备之并发基础

JDK1.8:

Java面试准备之并发基础

从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈

知识点扩展

为什么程序计数器、虚拟机栈和本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?

程序计数器为什么是私有的?

程序计数器主要有下面两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

虚拟机栈和本地方法栈为什么是私有的?

  • 虚拟机栈:每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈:和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

创建线程有几种不同的方式?你喜欢哪一种?为什么?

  1. 继承 Thread 类(真正意义上的线程类),重写 run 方法,其中 Thread 是 Runnable 接口的实现。
  2. 实现 Runnable 接口,并重写里面的 run 方法。
  3. 使用 Executor 框架创建线程池。Executor 框架是 juc 里提供的线程池的实现。
  4. 实现 callable 接口,重写 call 方法,有返回值。

一般情况下使用 Runnable 接口,避免单继承的局限,一个类可以继承多个接口;适合于资源的共享。

概括的解释下线程的几种可用状态。

  1. 新建( new ):新创建了一个线程对象。
  2. 可运行( runnable ):线程对象创建后,其他线程(比如 main 线程)调用了该对象 的 start ()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 cpu 的使用权 。
  3. 运行( running ):可运行状态( runnable )的线程获得了 cpu 时间片( timeslice ) ,执行程序代码。
  4. 阻塞( block ):阻塞状态是指线程因为某种原因放弃了 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 )状态。
    1. 死亡( dead ):线程 run ()、 main () 方法执行结束,或者因异常退出了 run ()方法,则该线程结束生命周期。死亡的线程不可再次复生。

Java面试准备之并发基础

守护线程是什么?

守护线程(即daemon thread),是个服务线程,又名后台线程,准确地来说就是服务其他的线程。调用 setDaemon(true) 方法可以使当前线程变为守护线程,注意是在 start 方法前执行。

并行和并发有什么区别?

  • 并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔发生。
  • 并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。
  • 在一台处理器上“同时”处理多个任务指的是并发,在多台处理器上同时处理多个任务指的是并行。如 hadoop 分布式集群。
    并发编程的目标是充分的利用处理器的每一个核,以达到最高的处理性能。

首先给出结论:“并行”概念是“并发”概念的一个子集。我们经常听说这样一个关键词“多线程并发编程”,一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码。

如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。

如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。

推荐阅读:并发与并行的区别? - Limbo的回答 - 知乎

runnable 和 callable 有什么区别?

  • 实现 Callable 接口的任务线程能返回执行结果;而实现 Runnable 接口的任务线程不能返回结果;
  • Callable 接口的 call()方法允许抛出异常;而 Runnable 接口的 run()方法的异常只能在内部消化,不能继续上抛;

注意:Callable 接口支持返回执行结果,此时需要调用 FutureTask.get()方法实现,此方法会阻塞主线程直到获取‘将来’结果;当不调用此方法时,主线程不会阻塞!

package interview.threadLearn;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class CallableImpl implements Callable<String> {
    private String acceptStr;
    public CallableImpl(String acceptStr){
        this.acceptStr = acceptStr;
    }

    @Override
    public String call() throws Exception {
//        int i = 1/0;
        Thread.sleep(3000);
        System.out.println("hello : " + this.acceptStr);
        return this.acceptStr + " append some chars and return it!";
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<String> callable = new CallableImpl("my callable test!");
        FutureTask<String> task = new FutureTask<>(callable);
        long startTime = System.currentTimeMillis();

        //创建线程
        new Thread(task).start();
        // 调用get()阻塞主线程,反之,线程不会阻塞
        String result = task.get();

        long endTime = System.currentTimeMillis();
        System.out.println("hello : " + result);
        System.out.println("cast : " + (endTime - startTime) / 1000 + " second!");
    }
}

//执行结果为:
hello : my callable test!
hello : my callable test! append some chars and return it!
cast : 3 second!

//如果注释get()方法,结果变为:
cast : 0 second!
hello : my callable test!

为什么要使用多线程呢?

先从总体上来说:

  • 从计算机底层来说:线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
  • 从当代互联网发展趋势来说:现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

再深入到计算机底层来探讨:

  • 单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。
  • 多核时代: 多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。

使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率,进而提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、死锁还有受限于硬件和软件的资源闲置问题

什么是上下文切换?

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新置为就绪状态,把 CPU 使用权让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回到这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少

为何要使用同步?

Java 允许多线程并发控制,当多个线程同时操作一个可共享的资源变量时(如数据的增删改查),将会导致数据不准确,相互之间产生冲突,因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用, 从而保证了该变量的唯一性和准确性。

同步方法和同步代码块的区别是什么?

同步方法

即有 synchronized 关键字修饰的方法。由于 Java 的每个对象都有一个内置锁,当用此关键字修饰方法时, 内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。

代码如: public synchronized void save(){}

注意: synchronized 关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类

同步代码块

即有 synchronized 关键字修饰的语句块。被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。

代码如: synchronized(object){}

注意:同步是一种高开销的操作,因此应该尽量减少同步的内容。
通常没有必要同步整个方法,使用 synchronized 代码块同步关键代码即可。

同步方法默认用 this 或者当前类 class 对象作为锁。

同步代码块可以选择以什么来加锁,比同步方法要更颗粒化,我们可以选择只同步会发生问题的部分代码而不是整个方法。

在监视器(Monitor)内部,是如何做线程同步的?程序应该做哪种级别的同步?

在 Java 虚拟机中, 每个对象( Object 和 class )通过某种逻辑关联监视器,每个监视器和一个对象引用相关联, 为了实现监视器的互斥功能, 每个对象都关联着一把锁。

一旦方法或者代码块被 synchronized 修饰, 那么这部分就放入了监视器的监视区域, 确保一次只能有一个线程执行该部分的代码, 线程在获取锁之前不允许执行该部分的代码。

另外 Java 还提供了显式监视器( Lock )和隐式监视器( synchronized )两种锁方案。

sleep() 和 wait() 有什么区别?

  1. 这两个方法来自不同的类分别是 Thread 和 Object;
  2. sleep 方法没有释放同步锁,但是 wait 方法释放了锁,使得其他线程可以使用同步控制块;
  3. sleep 可以在任何地方使用,wait notify notifyall 只能使用在同步控制块中;
  4. sleep 通常被用于暂停执行,wait 通常被用于线程间交互/通信,。
  5. sleep(milliseconds)可以用时间指定来使它自动醒过来,如果时间不到你只能调用 interreput()来强行打断;wait()可以用 notify()直接唤起。

锁池和等待池的概念

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

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

notify()和 notifyAll()有什么区别?

  • 如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
  • 当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了 notify 后只有一个线程会由等待池进入锁池,而 notifyAll 会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。
  • 优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

Java 中的 main 线程是不是最后一个退出的线程?

  1. JVM 会在所有的非守护线程(用户线程,又名前台线程)执行完毕后退出;
  2. main 线程是用户线程;
  3. 仅有 main 线程一个用户线程执行完毕,不能决定 JVM 是否退出,也即是说 main 线程并不一定是最后一个退出的线程。

线程的 run()和 start()有什么区别?

run()方法:

方法 run()称为线程体,可以重复多次调用;如果直接调用 run(),其实就相当于是调用了一个普通函数而已。

start()方法:

用启动一个线程,真正实现了多线程运行。不能多次启动同一个线程;

public class ThreadTest {

    public static class ThreadDemo extends Thread{
        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                System.out.println("This is a Thread test"+i);
            }
            System.out.println("Thread Priority:"+this.getPriority());
        }
    }

    //观察直接调用run()和用start()启动一个线程的差别
    public static void main(String[] args) {
        Thread thread = new ThreadDemo();

        System.out.println("Main Thread Priority = " + Thread.currentThread().getPriority());
        //第一种
        //表明: run()和其他方法的调用没任何不同,main方法按顺序执行了它,并打印出最后一句
//        thread.run();

        //第二种
        //表明: start()方法启动线程,由于main线程和thread线程都是用户线程(非守护线程),且优先级一致,因此在本程序中main线程退出后,
        //thread线程才进入运行状态执行代码,等所有的用户线程都退出后,jvm才退出。
        //main线程是用户线程,从main方法中构建的线程默认也是用户线程,且优先级相等
//        thread.start();

        //第三种
        //为什么没有打印出100句呢?因为我们将thread线程设置为了daemon(守护)线程,程序中main线程退出后,只有守护线程存在的时候,JVM随时可以退出,所以随机打印了几句
        //2、当java虚拟机中有守护线程在运行的时候,java虚拟机会关闭。当所有常规线程运行完毕以后,
        //守护线程不管运行到哪里,虚拟机都会退出运行。所以你的守护线程最好不要写一些会影响程序的业务逻辑。否则无法预料程序到底会出现什么问题
//        thread.setDaemon(true);
//        thread.start();

        //第四种
        //用户线程可以被System.exit(0)强制kill掉,JVM便可以退出,所以随机打印了几句
        thread.start();
        System.out.println("main thread is over");
        System.exit(1);
    }
}

什么是死锁(deadlock)?

死锁 :是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

(1) 因为系统资源不足。
(2) 进程运行推进顺序不合适。
(3) 资源分配不当等。

死锁产生的4个必要条件:

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  • 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。
  • 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  • 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被链中下一个进程所请求。

手写一个死锁

public class TestThread {
    public static void main(String[] args) {
// test dead lock
        Thread t9 = new Thread(
                new DeadLock(true));
        Thread t10 = new Thread(
                new DeadLock(false));
        t9.start();
        t10.start();
    }
}

class DeadLock implements Runnable{

    boolean lockFormer;
    static Object o1 = new Object();
    static Object o2 = new Object();
    DeadLock(boolean lockFormer){
        this.lockFormer = lockFormer;
    }

    @Override
    public void run() {
        if(this.lockFormer){
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("1ok");
                }
            }
        }else{
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("1ok");
                }
            }
        }

    }
}

如何确保N个线程可以访问N个资源同时又不导致死锁?

使用多线程的时候,一种非常简单的避免死锁的方式就是:指定获取锁的顺序,并强制线程按照指定的顺序获取锁。如果所有的线程都是以同样的顺序加锁和释放锁,就不会出现死锁了。

为什么下述程序可以正常结束?

public class InfiniteLoop {
    boolean stop = false;

    public static void main(String[] args) throws InterruptedException {

        final InfiniteLoop iv = new InfiniteLoop();

        Thread t1 = new Thread(() -> {
            while (!iv.stop) {
                //uncomment this block of code, loop broken
//                try {
//                    Thread.sleep(100);
//                } catch (InterruptedException e) {
//                    e.printStackTrace();
//                }
            }
            System.out.println("done");
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            iv.stop = true;
        });
        t1.start();
        t2.st

关于 sleep 我们可以看官方文档,文档中的 while 循环中的 done 也是没有被 volatile 修饰的。

Java面试准备之并发基础

里面有两句话特别重要(上面红框圈起来的部分):

1.Thread.sleep 没有任何同步语义(Thread.yield也是)。编译器不必在调用 Thread.sleep 之前将缓存在寄存器中的写刷新到共享内存,也不必在调用 Thread.sleep 之后重新加载缓存在寄存器中的值。

2.编译器可以自由(free)读取 done 这个字段仅一次。

发表评论 取消回复
表情 图片 链接 代码

分享