Java 并发基础知识

时间:2019-06-18
本文章向大家介绍Java 并发基础知识,主要包括Java 并发基础知识使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

一、什么是线程和进程?

进程:

是程序的一次执行过程,是系统运行程序的基本单元(就比如打开某个应用,就是开启了一个进程),因此进程是动态的。系统运行一个程序即是一个程序从创建、运行到消亡的过程。

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

线程:

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

二、线程与进程的关系,区别及优缺点?

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

下图是 Java 内存区域,通过下图我们从 JVM 的角度说明线程与进程之间的关系。

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

综上:线程是进程划分成的更小的运行单位。线程与进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程则相反。

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

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

首先明确程序计数器的作用:

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

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

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

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

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

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

(3)  堆和方法区

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

 参考:JavaGuide 公众号及其相应的 Github

 三、并发和并行有什么区别?

  • 并发:同一时间段,多个任务都在执行(单位时间内不一定同时执行);
  • 并行:单位时间内,多个任务同时执行。

并发的关键是你有处理多个任务的能力,不一定要同时。 而并行的关键是你有同时处理多个任务的能力。  

四、为什么要使用多线程?

先总体上:

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

再深入到计算机底层:

  • 单核时代:在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。
  • 多核时代:多核时代主要是为了提高 CPU 的利用率。

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

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

六、说说线程的生命周期和状态。

Java 线程在运行的生命周期中的指定时刻只可能指定处于下面 6 种不同状态的其中一个状态

线程在生命周期中并不是固定处于一个状态,而是随着代码的执行在不同状态之间切换。Java 线程状态变迁如下图(图为《Java 并发编程的艺术》)

可以看出:线程创建之初处于 NEW (新建) 状态。调用 start() 方法后开始运行,线程这时候处于 READY (可运行) 状态。可运行状态的线程获得了 CPU 时间片 (timeslice) 后就处于 RUNNING (运行)状态。线程执行了 wait() 方法后,线程进入 WAITING (超时等待) 状态相当于等待状态的基础上增加了超时限制,比如 sleep(long millis) 方法或 waiting(long millis) 方法可以将 Java 线程置于 TIME WAITING 状态。当超时时间达到后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED (阻塞)状态。线程在执行 Runnable 的 run() 方法之后将进入到 TERMINATED (终止) 状态。

七、什么是上下文切换?

多线程编程中一般线程的个数都大于 CPU 核的个数,而一个 CPU 核在任意时刻只能被一个线程使用,为了让这些县城都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程是时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。也就是:当任务执行完, CPU 时间片切换到另一个任务之前会先保存自己的状态,以便于再切换回这个任务时,可以加载这个任务的状态。任务从保持到再加载的过程就是一个上下文切换。

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

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

八、什么是线程死锁?怎么避免?

死锁:

两个或者两个以上的线程在执行的过程中,因争夺资源产生的一种互相等待的现象。

如下代码(代码源自《Java多线程编程核心技术》):

public class DeadThreadDemo implements Runnable{
    public String username;
    public Object lock1 = new Object();
    public Object lock2 = new Object();
    public void setFlag(String username) {
        this.username = username;
    }
    @Override
    public void run(){
        if(username.equals("a")) {
            synchronized (lock1) {
                try {
                    System.out.println("username = " + username);
                    Thread.sleep(3000);
                }catch(InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("按 lock1->lock2代码 顺序执行了");
                }
            }
        }
        if(username.equals("b")) {
            synchronized (lock2) {
                try {
                    System.out.println("username = " + username);
                    Thread.sleep(3000);
                }catch(InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("按lock2->lock1代码顺序执行了");
                }
            }
        }
    }
}

测试类:

public class DeadThreadTest {

    public static void main(String[] args) {
        try {
            DeadThreadDemo dtd1 = new DeadThreadDemo();
            dtd1.setFlag("a");
            Thread thread1 = new Thread(dtd1);
            thread1.start();
            Thread.sleep(100);
            dtd1.setFlag("b");
            Thread thread2 = new Thread(dtd1);
            thread2.start();
        }catch(InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出:

username = a
username = b

线程 a 通过 synchronized (lock1) 获得 lock1 的监视器锁,然后通过thread.sleap(3000); 让线程 a 休眠 3s 为的是让线程 b 得到执行然后获取到 lock2 的监视器锁。线程 a 和线程 b 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。

思索产生的四个条件:

  1. 互斥条件: 该资源任意一个时刻只由一个线程占用;
  2. 请求与保持条件:一个线程因请求资源而阻塞,对已获得的资源保持不放;
  3. 不剥夺条件:线程已经获得的资源在未使用完之前不能被其他线程强行剥夺,只由自己使用完毕后才释放资源;
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

怎么避免线程死锁?

只需要破坏产生死锁的四个条件之一即可。

破坏互斥条件

这个条件我们没有办法破坏,因为我们用锁本身就是想让他们互斥的(临界资源需要互斥访问)。

破坏请求与保持条件

一次性申请所有的资源

破坏不剥夺条件

占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

破坏循环等待条件

靠按顺序申请资源来预防。按照某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

九、sleep() 方法和 wait() 方法区别和共同点

  • 两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁;
  • 两者都可以暂停多线程;
  • wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行;
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep 执行完后,会自动苏醒。

十、为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

new 一个 Thread,线程进入了新建状态;调用 start() 方法,会启动一个线程并使线程进入就绪状态,当分配到时间片后就可以开始运行了。start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。而直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程zh9o执行它,所以这不是多线程工作。

总之:调用 start() 方法可启动线程并使线程进入就绪状态,而 run() 方法只是 thread 的一个普通方法,还是在主线程里执行的。

原文地址:https://www.cnblogs.com/reformdai/p/11039843.html