美文网首页程序员Leetcodejava学习
互联网校招面试必备——Java多线程

互联网校招面试必备——Java多线程

作者: 繁著 | 来源:发表于2018-09-19 01:25 被阅读141次

    本文首发于我的个人博客:尾尾部落

    本文是我刷了几十篇一线互联网校招java后端开发岗位的面经后总结的多线程相关题目,虽然有点小长,但是面试前看一看,相信能帮你轻松啃下多线程这块大骨头。

    什么是进程,什么是线程?为什么需要多线程编程?
    进程间的通信方式、线程间的通信方式
    实现多线程的三种方法
    三种创建多线程方法的对比
    线程状态
    线程控制
    wait、notify、notifyAll的区别
    sleep() 和 wait() 有什么区别?
    锁类型
    什么是乐观锁和悲观锁
    乐观锁的实现方式(CAS)
    CAS的缺点
    实现一个死锁
    如何确保 N 个线程可以访问 N 个资源同时又不导致死锁?
    volatile
    volatile使用建议
    volatile和synchronized区别
    synchronized
    synchronized的三种应用方式
    Lock
    Lock接口中获取锁的方法
    Condition类
    Condition与Object中的wait, notify, notifyAll区别
    synchronized和lock的区别
    锁的状态
    偏向锁、轻量级锁、重量级锁、自旋锁、自适应自旋锁
    偏向锁、轻量级锁、重量级锁适用于不同的并发场景
    AQS
    线程池
    使用线程池的好处
    线程池都有哪几种工作队列

    什么是进程,什么是线程?为什么需要多线程编程?

    进程是执行着的应用程序,而线程是进程内部的一个执行序列。一个进程可以有多个线程。线程又叫做轻量级进程。
    进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,是操作系统进行资源分配和调度的一个独立单位;线程是进程的一个实体,是 CPU 调度和分派的基本单位,是比进程更小的能独立运行的基本单位。线程的划分尺度小于进程,这使得多线程程序的并发性高;进程在执行时通常拥有独立的内存单元,而线程之间可以共享内存。使用多线程的编程通常能够带来更好的性能和用户体验,但是多线程的程序对于其他程序是不友好的,因为它占用了更多的 CPU 资源。

    进程间的通信方式

    • 管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
    • 有名管道 (namedpipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
    • 信号量(semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
    • 消息队列( messagequeue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
    • 信号 (sinal) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
    • 共享内存(shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
    • 套接字(socket ) : 套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。

    线程间的通信方式

    • 锁机制:包括互斥锁、条件变量、读写锁
      • 互斥锁提供了以排他方式防止数据结构被并发修改的方法。
      • 读写锁允许多个线程同时读共享数据,而对写操作是互斥的。
      • 条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
    • 信号量机制(Semaphore):包括无名线程信号量和命名线程信号量
    • 信号机制(Signal):类似进程间的信号处理

    线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。

    实现多线程的三种方法

    • 继承Thread类,重写父类run()方法
    public class thread1 extends Thread {
            public void run() {
                    for (int i = 0; i < 10000; i++) {
                            System.out.println("我是线程"+this.getId());
                    }
            }
            public static void main(String[] args) {
                    thread1 th1 = new thread1();
                    thread1 th2 = new thread1();
                    th1.start();
                    th2.start();
            }
    }
    
    • 实现runnable接口
    public class thread2 implements Runnable {
            public String ThreadName;
            public thread2(String tName){
                    ThreadName = tName;
            }
            public void run() {
                    for (int i = 0; i < 10000; i++) {
                            System.out.println(ThreadName);
                    }
            }
            public static void main(String[] args) {
                    // 创建一个Runnable接口实现类的对象
                    thread2 th1 = new thread2("线程A:");
                    thread2 th2 = new thread2("线程B:");
                    // 将此对象作为形参传递给Thread类的构造器中,创建Thread类的对象,此对象即为一个线程
                    Thread myth1 = new Thread(th1);
                    Thread myth2 = new Thread(th2);
                    // 调用start()方法,启动线程并执行run()方法
                    myth1.start();
                    myth2.start();
            }
    }
    
    • 通过Callable和Future创建线程
    import java.util.concurrent.Callable;
    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.FutureTask;
     
    public class CallableThreadTest implements Callable<Integer>
    {
        @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<Integer> ft = new FutureTask<>(ctt);
            for(int i = 0;i < 100;i++){
                System.out.println(Thread.currentThread().getName()+" 的循环变量i的值"+i);
                if(i==20){
                    new Thread(ft,"有返回值的线程").start();
                }
            }
            try{
                System.out.println("子线程的返回值:"+ft.get());
            } catch (InterruptedException e){
                e.printStackTrace();
            } catch (ExecutionException e){
                e.printStackTrace();
            }
        }
    }
    

    三种创建多线程方法的对比

    1、采用实现Runnable、Callable接口的方式创建多线程时,线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。缺点是编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。
    2、使用继承Thread类的方式创建多线程时,编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。缺点是线程类已经继承了Thread类,所以不能再继承其他父类。
    3、Runnable和Callable的区别
    (1) Callable规定重写call(),Runnable重写run()。
    (2) Callable的任务执行后可返回值,而Runnable的任务是不能返回值的。
    (3) call方法可以抛出异常,run方法不可以。
    (4) 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。

    线程状态

    image
    • 新建状态:新建线程对象,并没有调用start()方法之前
    • 就绪状态:调用start()方法之后线程就进入就绪状态,但是并不是说只要调用start()方法线程就马上变为当前线程,在变为当前线程之前都是为就绪状态。值得一提的是,线程在睡眠和挂起中恢复的时候也会进入就绪状态。
    • 运行状态:线程被设置为当前线程,获得CPU后,开始执行run()方法,就是线程进入运行状态。
    • 阻塞状态:处于运行的状态的线程,除非执行时间非常非常非常短,否则它会因为系统对资源的调度而被中断进入阻塞状态。比如说调用sleep()方法后线程就进入阻塞状态。
    • 死亡状态:处于运行状态的线程,当它主动或者被动结束,线程就处于死亡状态。结束的形式,通常有以下几种:1. 线程执行完成,线程正常结束;2. 线程执行过程中出现异常或者错误,被动结束;3. 线程主动调用stop方法结束线程。

    线程控制

    • join():等待。让一个线程等待另一个线程完成才继续执行。如A线程线程执行体中调用B线程的join()方法,则A线程被阻塞,知道B线程执行完为止,A才能得以继续执行。
    • sleep():睡眠。让当前的正在执行的线程暂停指定的时间,并进入阻塞状态。
    • yield():线程让步。将线程从运行状态转换为就绪状态。当某个线程调用 yiled() 方法从运行状态转换到就绪状态后,CPU 会从就绪状态线程队列中只会选择与该线程优先级相同或优先级更高的线程去执行。
    • setPriority():改变线程的优先级。每个线程在执行时都具有一定的优先级,优先级高的线程具有较多的执行机会。每个线程默认的优先级都与创建它的线程的优先级相同。main线程默认具有普通优先级。参数priorityLevel范围在1-10之间,常用的有如下三个静态常量值:MAX_PRIORITY:10;MIN_PRIORITY:1;NORM_PRIORITY:5。

    PS: 具有较高线程优先级的线程对象仅表示此线程具有较多的执行机会,而非优先执行。

    • setDaemon(true):设置为后台线程。后台线程主要是为其他线程(相对可以称之为前台线程)提供服务,或“守护线程”。如JVM中的垃圾回收线程。当所有的前台线程都进入死亡状态时,后台线程会自动死亡。

    sleep() 和 yield() 两者的区别:
    ① sleep()方法会给其他线程运行的机会,不考虑其他线程的优先级,因此会给较低优先级线程一个运行的机会。yield()方法只会给相同优先级或者更高优先级的线程一个运行的机会。
    ② 当线程执行了 sleep(long millis) 方法,将转到阻塞状态,参数millis指定睡眠时间。当线程执行了yield()方法,将转到就绪状态。
    ③ sleep() 方法声明抛出InterruptedException异常,而 yield() 方法没有声明抛出任何异常。

    wait、notify、notifyAll的区别

    wait、notify、notifyAll是java同步机制中重要的组成部分,结合synchronized关键字使用,可以建立很多优秀的同步模型。这3个方法并不是Thread类或者是Runnable接口的方法,而是Object类的3个本地方法。
    调用一个Object的wait与notify/notifyAll的时候,必须保证调用代码对该Object是同步的,也就是说必须在作用等同于synchronized(obj){......}的内部才能够去调用obj的wait与notify/notifyAll三个方法,否则就会报错:java.lang.IllegalMonitorStateException:current thread not owner

    先说两个概念:锁池和等待池
    锁池:假设线程A已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。
    等待池:假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁后,进入到了该对象的等待池中
    @知乎--文龙

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

    小结

    • wait:线程自动释放其占有的对象锁,并等待notify
    • notify:唤醒一个正在wait当前对象锁的线程,并让它拿到对象锁
    • notifyAll:唤醒所有正在wait当前对象锁的线程
      notify和notifyAll的最主要的区别是:notify只是唤醒一个正在wait当前对象锁的线程,而notifyAll唤醒所有。值得注意的是:notify是本地方法,具体唤醒哪一个线程由虚拟机控制;notifyAll后并不是所有的线程都能马上往下执行,它们只是跳出了wait状态,接下来它们还会是竞争对象锁。

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

    sleep()方法是线程类(Thread)的静态方法,导致此线程暂停执行指定时间,将执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复(线程回到就绪(ready)状态),因为调用 sleep 不会释放对象锁。wait() 是 Object 类的方法,对此对象调用 wait()方法导致本线程放弃对象锁(线程暂停执行),进入等待此对象的等待锁定池,只有针对此对象发出 notify 方法(或 notifyAll)后本线程才进入对象锁定池准备获得对象锁进入就绪状态。

    锁类型

    • 可重入锁:广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。即在执行对象中所有同步方法不用再次获得锁。ReentrantLock和synchronized都是可重入锁。举个简单的例子,当一个线程执行到某个synchronized方法时,比如说method1,而在method1中会调用另外一个synchronized方法method2,此时线程不必重新去申请锁,而是可以直接执行方法method2。
    • 可中断锁:在等待获取锁过程中可中断。synchronized就不是可中断锁,而Lock是可中断锁。
    • 公平锁: 按等待获取锁的线程的等待时间进行获取,等待时间长的具有优先获取锁权利。非公平锁即无法保证锁的获取是按照请求锁的顺序进行的,这样就可能导致某个或者一些线程永远获取不到锁。synchronized是非公平锁,它无法保证等待的线程获取锁的顺序。对于ReentrantLock和ReentrantReadWriteLock,默认情况下是非公平锁,但是可以设置为公平锁。
    • 读写锁:对资源读取和写入的时候拆分为2部分处理,一个读锁和一个写锁。读的时候可以多线程一起读,写的时候必须同步地写。ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。可以通过readLock()获取读锁,通过writeLock()获取写锁。

    什么是乐观锁和悲观锁

    (1)乐观锁:很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会去判断在此期间有没有人去更新这个数据(可以使用版本号等机制)。如果因为冲突失败就重试。乐观锁适用于写比较少的情况下,即冲突比较少发生,这样可以省去了锁的开销,加大了系统的整个吞吐量。像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
    (2)悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,因此每次拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁,效率比较低。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。

    乐观锁的实现方式(CAS)

    乐观锁的实现主要就两个步骤:冲突检测和数据更新。其实现方式有一种比较典型的就是 Compare and Swap ( CAS )。
    CAS:CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
    CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。 ”这其实和乐观锁的冲突检查+数据更新的原理是一样的。

    乐观锁是一种思想,CAS是这种思想的一种实现方式。

    CAS的缺点

    1. ABA问题

    如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。ava并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

    1. 循环时间长开销很大

    自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。

    1. 只能保证一个共享变量的原子操作。

    当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。

    实现一个死锁

    什么是死锁:两个进程都在等待对方执行完毕才能继续往下执行的时候就发生了死锁。结果就是两个进程都陷入了无限的等待中。
    产生死锁的四个必要条件:
    互斥条件:一个资源每次只能被一个进程使用。
    请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
    不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
    循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
    这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
    考虑如下情形:
    (1)线程A当前持有互斥所锁lock1,线程B当前持有互斥锁lock2。
    (2)线程A试图获取lock2,因为线程B正持有lock2,因此线程A会阻塞等待线程B对lock2释放。
    (3)如果此时线程B也在试图获取lock1,同理线程也会阻塞。
    (4)两者都在等待对方所持有但是双方都不释放的锁,这时便会一直阻塞形成死锁。
    死锁的解决方法:
    a 撤消陷于死锁的全部进程;
    b 逐个撤消陷于死锁的进程,直到死锁不存在;
    c 从陷于死锁的进程中逐个强迫放弃所占用的资源,直至死锁消失。
    d 从另外一些进程那里强行剥夺足够数量的资源分配给死锁进程,以解除死锁状态

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

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

    volatile关键字

    对于过可见性、有序性及原子性问题,通常情况下我们可以通过Synchronized关键字来解决这些个问题,不过如果对Synchronized原理有了解的话,应该知道Synchronized是一个比较重量级的操作,对系统的性能有比较大的影响,所以,如果有其他解决方案,我们通常都避免使用Synchronized来解决问题。而volatile关键字就是Java中提供的另一种解决可见性和有序性问题的方案。对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。

    • 防止重排序

    问题:操作系统可以对指令进行重排序,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果
    解决原理:volatile关键字通过提供“内存屏障”的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
    1、在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
    2、在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

    • 实现可见性

    问题:可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到
    解决原理:(1)修改volatile变量时会强制将修改后的值刷新的主内存中。
    (2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。

    • 注:volatile并不保证变量更新的原子性

    volatile使用建议

    相对于synchronized块的代码锁,volatile应该是提供了一个轻量级的针对共享变量的锁,当我们在多个线程间使用共享变量进行通信的时候需要考虑将共享变量用volatile来修饰。
    volatile是一种稍弱的同步机制,在访问volatile变量时不会执行加锁操作,也就不会执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
    使用建议:在两个或者更多的线程需要访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile。
    由于使用volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。

    volatile和synchronized区别

    1、volatile不会进行加锁操作:
    volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
    2、volatile变量作用类似于同步变量读写操作:
    从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。
    3、volatile不如synchronized安全:
    在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。
    4、volatile无法同时保证内存可见性和原子性:
    加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

    当且仅当满足以下所有条件时,才应该使用volatile变量:
    1、对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
    2、该变量没有包含在具有其他变量的不变式中。
    总结:在需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在、jdK1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。

    synchronized

    synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。Synchronized主要有以下三个作用:保证互斥性、保证可见性、保证顺序性。

    synchronized的三种应用方式

    • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。实现原理:指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。

      public synchronized void increase(){
          i++;
      }
      
    • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

      public static synchronized void increase(){
          i++;
      }
      
    • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。实现原理:使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。

      static AccountingSync instance=new AccountingSync();
      synchronized(instance){
          for(int j=0;j<1000000;j++){
              i++;
          }
      }
      

    Lock

    Lock是一个接口,它的的实现类提供了比synchronized更广泛意义上锁操作,它允许用户更灵活的代码结构,更多的不同特效。Lock的实现类主要有ReentrantLock和ReentrantReadWriteLock。

    Lock lock=new ReentrantLock();
    lock.lock();
    try{
        // do something
        // 如果有return要写在try块中
    }finally{
        lock.unlock();
    }
    

    Lock接口中获取锁的方法

    • void lock():lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。在发生异常时,它不会自动释放锁,要记得在finally块中释放锁,以保证锁一定被被释放,防止死锁的发生。
    • void lockInterruptibly():可以响应中断,当通过这个方法去获取锁时,如果线程 正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。
    • boolean tryLock():有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true;如果获取失败(即锁已被其他线程获取),则返回false。
    • boolean tryLock(long time, TimeUnit unit):和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false,同时可以响应中断。

    Condition类

    Condition是Java提供来实现等待/通知的类,Condition类还提供比wait/notify更丰富的功能,Condition对象是由lock对象所创建的。但是同一个锁可以创建多个Condition的对象,即创建多个对象监视器。这样的好处就是可以指定唤醒线程。notify唤醒的线程是随机唤醒一个。
    Condition 将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set (wait-set)。
    其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。
    在Condition中,用await()替换wait(),用signal()替换notify(),用signalAll()替换notifyAll(),传统线程的通信方式,Condition都可以实现,这里注意,Condition是被绑定到Lock上的,要创建一个Lock的Condition必须用newCondition()方法。

    Condition与Object中的wait, notify, notifyAll区别

    1.Condition中的await()方法相当于Object的wait()方法,Condition中的signal()方法相当于Object的notify()方法,Condition中的signalAll()相当于Object的notifyAll()方法。
    不同的是,Object中的这些方法是和同步锁捆绑使用的;而Condition是需要与互斥锁/共享锁捆绑使用的。
    2.Condition它更强大的地方在于:能够更加精细的控制多线程的休眠与唤醒。对于同一个锁,我们可以创建多个Condition,在不同的情况下使用不同的Condition。
    例如,假如多线程读/写同一个缓冲区:当向缓冲区中写入数据之后,唤醒"读线程";当从缓冲区读出数据之后,唤醒"写线程";并且当缓冲区满的时候,"写线程"需要等待;当缓冲区为空时,"读线程"需要等待。
    如果采用Object类中的wait(),notify(),notifyAll()实现该缓冲区,当向缓冲区写入数据之后需要唤醒"读线程"时,不可能通过notify()或notifyAll()明确的指定唤醒"读线程",而只能通过notifyAll唤醒所有线程(但是notifyAll无法区分唤醒的线程是读线程,还是写线程)。 但是,通过Condition,就能明确的指定唤醒读线程。

    synchronized和lock的区别

    synchronized Lock
    存在层次 Java的关键字 是一个接口
    锁的释放 1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁 在finally中必须释放锁,不然容易造成线程死锁
    锁的获取 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 Lock可以让等待锁的线程响应中断
    锁状态 无法判断 可以判断有没有成功获取锁
    锁类型 可重入 不可中断 非公平 可重入 可中断 公平/非公平

    性能方面,JDK1.5中,synchronized是性能低效的。因为这是一个重量级操作,它对性能最大的影响是阻塞的是实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力。相比之下使用Java提供的Lock对象,性能更高一些。多线程环境下,synchronized的吞吐量下降的非常严重,而ReentrankLock则能基本保持在同一个比较稳定的水平上。

    到了JDK1.6,synchronize加入了很多优化措施,有自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在JDK1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronize,在未来的版本中还有优化余地,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

    锁的状态

    Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

    偏向锁

    在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
    “偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
    偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。

    轻量级锁

    轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。轻量级锁是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
    使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。

    重量级锁

    重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

    自旋锁

    自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
    但是线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。
    如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

    自适应自旋锁

    自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:

    • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
    • 相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。

    自适应自旋解决的是“锁竞争时间不确定”的问题。JVM很难感知到确切的锁竞争时间,而交给用户分析就违反了JVM的设计初衷。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定,因此,可以根据上一次自旋的时间与结果调整下一次自旋的时间。

    偏向锁、轻量级锁、重量级锁适用于不同的并发场景

    偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁。
    轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。
    重量级锁:有实际竞争,且锁竞争时间长。
    另外,如果锁竞争时间短,可以使用自旋锁进一步优化轻量级锁、重量级锁的性能,减少线程切换。
    如果锁竞争程度逐渐提高(缓慢),那么从偏向锁逐步膨胀到重量锁,能够提高系统的整体性能。

    锁膨胀的过程:只有一个线程进入临界区(偏向锁),多个线程交替进入临界区(轻量级锁),多线程同时进入临界区(重量级锁)。

    AQS

    AQS即是AbstractQueuedSynchronizer,一个用来构建锁和同步工具的框架,包括常用的ReentrantLock、CountDownLatch、Semaphore等。
    AbstractQueuedSynchronizer是一个抽象类,主要是维护了一个int类型的state属性和一个非阻塞、先进先出的线程等待队列;其中state是用volatile修饰的,保证线程之间的可见性,队列的入队和出对操作都是无锁操作,基于自旋锁和CAS实现;另外AQS分为两种模式:独占模式和共享模式,像ReentrantLock是基于独占模式模式实现的,CountDownLatch、CyclicBarrier等是基于共享模式。

    线程池

    如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。
    线程池的产生和数据库的连接池类似,系统启动一个线程的代价是比较高昂的,如果在程序启动的时候就初始化一定数量的线程,放入线程池中,在需要是使用时从池子中去,用完再放回池子里,这样能大大的提高程序性能,再者,线程池的一些初始化配置,也可以有效的控制系统并发的数量,防止因为消耗过多的内存,而把服务器累趴下。

    通过Executors工具类可以创建各种类型的线程池,如下为常见的四种:

    • newCachedThreadPool :大小不受限,当线程释放时,可重用该线程;
    • newFixedThreadPool :大小固定,无可用线程时,任务需等待,直到有可用线程;
    • newSingleThreadExecutor :创建一个单线程,任务会按顺序依次执行;
    • newScheduledThreadPool:创建一个定长线程池,支持定时及周期性任务执行

    使用线程池的好处

    • 减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
    • 运用线程池能有效的控制线程最大并发数,可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)。
    • 对线程进行一些简单的管理,比如:延时执行、定时循环执行的策略等,运用线程池都能进行很好的实现

    线程池都有哪几种工作队列

    1、ArrayBlockingQueue
    是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
    2、LinkedBlockingQueue
    一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列
    3、SynchronousQueue
    一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
    4、PriorityBlockingQueue
    一个具有优先级的无限阻塞队列。

    参考

    Java 多线程
    Java并发:volatile内存可见性和指令重排
    并发编程的锁机制:synchronized和lock
    浅谈偏向锁、轻量级锁、重量级锁

    获取最新资讯,请关注微信公众号:南强说晚安

    image

    相关文章

      网友评论

        本文标题:互联网校招面试必备——Java多线程

        本文链接:https://www.haomeiwen.com/subject/cohbnftx.html