Java面试准备——多线程

Java多线程以及相关内容整理

本文学习自GitHub上的JavaGuide项目,感谢大佬的资源,此处为自我学习与整理,原项目链接 JavaGuide

什么是线程?什么是进程?

  1. 进程是程序的一次执行过程。进程是系统运行程序的基本单位,系统运行一个程序就是一个进程从创建,运行,到消亡的过程。在Java中启动一个main函数其实就启动了一个JVM进程,而main所在的线程就是这个进程的主线程。进程之间相互独立。
  2. 线程是比进程更小的执行单位,一个进程可以有一个或多个线程,这些线程共享进程分配的资源(堆,方法区),但是这些线程各自有自己的程序计数器,虚拟机栈和本地方法栈。系统在线程间切换开销远小于进程。一个Java程序的运行往往是一个main和多个线程运行。
  3. 线程是进程划分的更小的单位,进程之间基本是相互独立的,而线程共享同一进程中的堆和方法区资源,线程开销较小,但不利于资源的管理和保护,进程则相反。

从JVM角度出发看线程和进程的内存分配

一个进程中可以有多个线程,多个线程共享进程的方法区,但是每个线程有自己的程序计数器虚拟机栈本地方法栈

图源JavaGuide
在这里插入图片描述

程序计数器:程序计数器是线程私有的,作用如下

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制:顺序,循环,选择,异常处理等
  2. 多线程情况下,程序计数器用于记录当前线程执行的位置,从而线程切换时可以继续运行
  3. 如果执行的是native方法,程序计数器记录的是undefined地址,只有执行的是Java方法才会记录下一条指令地址。
  4. 综上,程序计数器私有的主要目的是可以在线程切换后回到正确的执行位置。

虚拟机栈:每个Java方法执行时会创建一个栈帧,用来储存局部变量,操作数栈,常量池引用等信息。方法的调用和完成就是这一个栈帧从入栈到出栈的过程。

本地方法栈:
虚拟机栈为虚拟机执行java方法服务,而本地方法栈为native方法服务。在HotSpot中将这两者合二为一。

所以为了线程中的局部变量不被别的线程影响,虚拟机栈和本地方法栈都是线程私有的

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

并发和并行的区别?

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

线程的生命周期和状态

如下链接我总结过线程的生命周期以及不同的状态如何切换。
Java基础知识

上下文切换

多线程编程中一般现成的个数都大于CPU的个数,每个CPU核心在同一时间只能处理一个线程。为了让线程都能够被执行,CPU采用时间片轮转执行的方式,当一个线程的时间片用完就会轮转到执行下一个线程,这个过程属于一次上下文切换。
当一个线程用完自己的时间片,就会保存当前的执行的状态,以便下一次继续执行,任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的,是非常耗时的,每次切换都需要纳秒量级的时间,消耗了CPU大量的时间。

线程死锁

多个线程同时被阻塞,他们中的一个或者多个在等待某个资源被释放,永远互相等待。

死锁的必须条件:

  1. 互斥条件:该资源同一时间只能属于一个线程
  2. 请求与保持条件:一个进程请求资源时,对已经拥有的资源保持不放
  3. 不可剥夺条件:线程已经获得的资源在自己释放之前不可以被强行剥夺
  4. 循环等待条件:若干个线程形成一种头尾相连的相互等待资源的状态

如何避免死锁?

  1. 破坏互斥条件:无法做到。锁存在的意义就是互斥
  2. 破坏请求与保持条件:一次性申请所有的资源
  3. 破坏不靠剥夺条件:占用部分资源的进程如果申请不到需要的资源,自动释放已经占有的资源
  4. 破坏循环等待条件:靠按顺序来申请资源来预防。按某一顺序申请资源,按逆序释放资源,就不会导致循环等待。

sleep()方法和wait()方法

  1. sleep()方法没有释放锁,而wait()方法释放锁。
  2. 两者都可暂停线程的进行。
  3. sleep()执行完以后线程会自动苏醒,而wait()需要等别的线程上同一对象调用notify()方法来继续执行,或者使用wait(long timeout)来设定超时苏醒。

为什么要调用start()而不直接调用run()?

new一个Thread时线程就被创建,调用start()方法会启动一个线程并且进入就绪状态。start()调用以后会配备线程所需要的准备,并且当时间片轮转到的时候执行线程。run()只是main中的一个方法,还是在主线程中执行的。

synchronized关键字

synchronized关键字解决了多个线程访问同一资源的同步性,被synchronized修饰的部分只能被单一线程访问。

synchronized关键字最主要的三种使用方式

  1. 修饰实例方法:作用于当前对象实例加锁,进入同步之前需要获得这个对象的锁
  2. 修饰静态方法:也就是给当前的类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员。所以如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B要调用这个实例对象所属类的静态synchronized方法,是允许的,不会产生排斥现象。因为访问静态synchronized方法占用的锁是当前类的锁,而访问非静态synchronized方法占用的锁是当前实例对象锁。
  3. 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块之前需要获得锁。

总结:synchronized加到static静态方法和synchronized(class)代码块都是给类上锁。加到实例方法上是给对象上锁。

synchronized和ReentrantLock的区别

  1. 两者都是可重入锁。可重入锁的意思是,自己可以再次获取自己的内部锁。比如某个线程获得了某个对象的锁,此时这个对象还没释放,当其再次想要获取这个锁还是可以获取的,如果不可重入的话此时就会造成死锁。同一个线程每次获取锁,锁的计数器都会加1,所以要等锁的计数器下降为0时才可以释放锁。
  2. synchronized依赖于JVM而ReentrantLock依赖于API。synchronized是依赖于JVM实现的,而ReentrantLock是JDK层面实现的,也就是API层面,需要lock()和unlock()方法配合try/finally语句块来完成,我们可以通过查看源码了解其工作原理。
  3. ReentrantLock具有一些高级功能。相比synchronized,ReentrantLock增加了高级功能。
    (1)等待可中断
    通过lock.lockInterruptiby()来实现这个功能,让在等待的线程终止等待,改为处理其他事情。
    (2)可实现公平锁
    synchronized只能是非公平锁。公平锁就是先等待的进程先获得锁。ReentrantLock默认是非公平锁,但是如果调用构造函数ReentrantLock(boolean isFair)可以选择性的创建一个公平锁。
    (3)可实现选择性通知(锁可绑定多个条件):synchronized通过wait()和notify()/notifyAll()可以实现等待唤醒,ReentrantLock也可以实现,但是需要借助Condition接口与newCondition()方法。Condition是1.5以后出现的,具有很好的灵活性,比如在一个Lock对象中创建多个Condition实例,线程对象可以注册在指定的Condition中,从而可以选择性的线程通知,被通知的线程是由JVM选择的,用ReentrantLock类结合Condition实例可以实现选择性通知。synchronized相当于只有一个Condition,notifyAll()执行时唤醒所有的处在等待状态的线程,而Condition的singleAll()方法只会唤醒注册在该Condition的等待线程

volatile关键字

在JDK1.2之前,Java的内存模型是直接在主存(共享内存)中读取变量,而1.2之后线程可以把变量从主存copy并且保存到本地内存(寄存器)中,对copy进行操作,再写入主存。这样就导致多线程情况下有可能一个线程已经修改了主存中内容,而另一个线程还去调用copy中的变量,造成数据冲突。
在这里插入图片描述
要解决这个问题,就要用到volatile关键字。将变量声明为volatile,JVM就会知道这个变量是不稳定的,每次读取都会直接从主存中操作。
在这里插入图片描述

volatile和synchronized关键字对比

  1. volatile关键字属于线程同步的轻量级实现,效率高于synchronized,但是volatile关键字只能用于修饰变量,而synchronized可以用于方法和代码块。在1.6之后synchronized进行了各种优化,使其效率显著提升,实际应用还是synchronized更常用一些。
  2. 多线程访问volatile不会阻塞,而synchronized会发生阻塞。
  3. volatile可以保证数据的可见性,但不保证原子性。synchronized则两者都能保证。
  4. volatile主要用于多个线程之间的可见性,而synchronized主要解决的事多个线程访问同一资源的同步性。

ThreadLocal

  1. ThreadLocal简介:通常情况下,我们创建的变量是可以被任何线程访问和修改的。而ThreadLocal为我们提供每个线程可以绑定自己的值,让每个线程有自己的私有的数据。
  2. ThreadLocal原理:最终的变量存放在ThreadLocalMap中,并不是ThreadLocal上;ThreadLocal可以理解为ThreadLocalMap的封装,传递变量值。ThreadLocal内部维护的是一个类似Map的ThreadLocalMap数据结构,key为当前的Thread对象值,value为Object对象。比如我们在同一线程中声明两个ThreadLocal对象的话,会使用Thread内部的ThreadLocalMap存放数据,ThreadLocalMap的key是ThreadLocal对象,value就是ThreadLocal对象调用的set()方法的值。
  3. ThreadLocal内存泄漏问题
    ThreadLocalMap中使用的key是ThreadLocal的弱引用,而value是强引用。所以如果ThreadLocal没有被外界强调用的话,GC时key会被回收而value不会被回收。这样一来ThreadLocalMap中就出现了key为null而value不为null的Entry。假如我们不做任何事,这个Entry就永远不会被GC,就会产生内存泄漏。ThreadLocal有处理这一问题的办法:在set(),add(),remove()中都会清理掉key为null的Entry,而我们在使用完ThreadLocal后最好手动调用remove()方法。

弱引用:如果一个对象被弱引用,就类似于可有可无的生活用品。弱引用在GC过程中不论当前空间够用与否都会回收这部分内存。不过由于GC线程的优先级很低,不一定很快发现那些只有弱引用的对象。

线程池

  1. 为什么使用线程池?
    降低资源消耗:通过重复利用已经创建的完线程降低创建和销毁线程所产生的的资源消耗。
    提高响应速度:当任务到达时,不需要等待线程创建就可以执行。
    提高线程的可管理型:线程如果无限制的创建,不但会消耗系统资源,还会降低系统稳定性。使用线程池可以进行统一的分配,调优和和监控。
  2. 实现Runnable和Callable接口的区别
    Runnable接口不会返回结果或抛出异常检查,但是Callable接口可以。Runnable从Java1.0就有,而Callable从1.5以后出现,为了解决Runnable中一些不能实现的用例。
    工具类Executor可以实现Runnable和Callable对象之间的相互转换。(Executor.callable(Runnable task) or Executor.callable(Runnable task, Object resule))。
    Runnable和Callable对比:
@FunctionalInterface
public interface Runnable {
   /**
	* 被线程执行,没有返回值也无法抛出异常 */
	public abstract void run();
}
@FunctionalInterface
public interface Callable<V> {
    /**
	* 计算结果,或在无法这样做时抛出异常。 * @return 计算得出的结果
	* @throws 如果无法计算结果,则抛出异常 */
    V call() throws Exception;
}
  1. 执行execute()和submit()方法的区别
    execute()方法用于提交不需要返回值的任务,所以无法判断被线程池成功执行与否
    submit()方法用于提交需要返回值的任务,线程池会返回一个Future类型的对象,通过这个对象可以判断线程是否成功执行,并且可以通过get()方法获取返回值。get()方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit timeunit)方法则会阻塞当前线程一段时间后立即返回,此时可能还有任务没有完成。

  2. 如何创建线程池
    阿里巴巴开发手册中告诉我们创建线程池不要用Executor而要用ThreadPoolExecutor。
    Executor的弊端如下:
    FixedThreadPool和SingleThreadPool:允许请求的队列长度为Integer.MAX_VALUE,可能堆积大量的请求导致OOM(Out_Of_Memory)
    CachedThreadPool和ScheduledThreadPool:允许创建的线程数量同上,可能创建大量线程导致OOM
    总结:使用Executor容易出现OOM问题,所以不用它。

    FixedThreadPool: 返回一个固定数量的线程池,该线程池中线程数量保持不变。如果新来一个任务,有空余线程可以执行则分配给这个线程,如果没有则进入队列等待空闲线程。
    SingleThreadPool: 返回一个只有一个线程的线程池,对到来的任务进行排队执行。
    CachedThreadPool: 返回一个线程数目不确定的线程池,对到来的每一个任务都分配线程,如果所有线程都在运行,则创建新线程来执行任务。当线程完成任务后会返回线程池等待调用。

ThreadPoolExecutor类分析
在这里插入图片描述
ThreadPoolExecutor提供了四个构造方法,理解最长的其他的就都知道了。

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)

corePoolSize: 核心线程数,定义了最小可以同时运行的线程数。

maximumPoolSize: 当队列中任务数量达到队列容量的时候,当前可同时运行的线程数改为最大线程数目。

workQueue: 当新任务到来的时候,判断当前运行线程数目是否达到核心线程数,达到的话任务加入这个队列等候执行。

keepAliveTime: 当线程池中的线程数目大于corePoolSize的时候,如果这时候没有新的任务,超过keepAliveTime核心线程以外的线程会被销毁。

unit: keepAliveTime的时间单位

threadFactory: Executor创建线程时会用到

handler: 饱和策略:如果当前线程数目达到最大值,而且队列也被放满了,ThreadPoolExecutor定义了一些策略:
ThreadPoolExecutor.AbortPolicy:抛出RejectedExecutionException,拒绝处理新任务
ThreadPoolExecutor.CallerRunsPolicy:此策略增加队列容量,使得任务都不被抛弃,但是影响程序的整体性能,会造成延迟
ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉
ThreadPoolExecutor.DiscardOldestPolicy: 丢弃掉最早的没处理完的任务
在这里插入图片描述
图源JavaGuide,ThreadPoolExecutor的执行过程

Atomic原子类

  1. Atomic是指一个操作是不可中断的。即使是多个线程一起执行,一个Atomic操作一旦开始就不会被其他线程干扰。原子类就是具有原子特性的类。
    Atomic包下面的类
    在这里插入图片描述

  2. JUC包中的原子类分为4类:
    基本类型:AtomicInteger,AtomicLong,AtomicBoolean
    数组类型:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
    引用类型:AtomicReference,AtomICStampedReference,AtomicMarkableReference
    对象的属性修改类型:AtomicIntegerFieldUpdater,etc

  3. 讲讲AtomicInteger的使用
    AtomicInteger常用方法:

public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子 方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之 后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
使用这些方法,就算不用加锁也可以实现线程安全。
  1. AtomicInteger原理
// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替 换”的作用)
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;
	static { 
		try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
    private volatile int value;

AtomicInteger主要利用了CAS+volatile+native方法来保证原子操作,避免了synchronized的高开销。
CAS的原理是拿期望的值和原本的的一个值作比较,如果相同则更新成新的值。Unsafe类的objectFieldOffset()方法是一个本地方法,这个方法是用来拿到”原来的值”的内存地址,返回值是valueOffset。value是一个volatile变量,在内存中可见,因此JVM总可以拿到该变量的最新值。

发布了11 篇原创文章 · 获赞 0 · 访问量 3046

猜你喜欢

转载自blog.csdn.net/weixin_40407203/article/details/105235334