java面试八股文之------Java并发夺命23问

Posted 归去来 兮

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java面试八股文之------Java并发夺命23问相关的知识,希望对你有一定的参考价值。

java面试八股文之------Java并发夺命23问

👨‍🎓1.java中线程的真正实现方式

这个问题的答案可以根据工作年限的不同来回答了,若是工作五年以下面试初中级开发时,可以回答三种或者四种都是可以的,哪三种呢?就是下面三种了,若是面试高开最好回答是一种,我们需要从原理上来解释下为什么是一种

  • 1.继承Thread类,重写run方法
    	public class Test1 extends Thread
        @Override
        public void run() 
            while(true)
                System.out.println("线程1");
            
    
        
    
        public static void main(String[] args) 
            new Test1().start();
        
    
    
  • 2.实现Runnable接口,重写run方法
    	public class TestThread 
        public static void main(String[] args) 
            new Thread(() -> 
                while(true)
                    System.out.println("Runnable多线程1");
            ).start();
            
            new Thread(() -> 
                while(true)
                    System.out.println("Runnable多线程2");
            ).start();
        
    
    
  • 3.实现Callable接口,重写call方法,利用FutureTask接收返回值
    	public class TestThread 
        public static void main(String[] args) throws Exception
            FutureTask<String> futureTask = new FutureTask<>(()->
                int i =0 ;
                while(i<100)
                    System.out.println("Callable线程1在执行:"+i++);
                return "线程1执行完了";
            );
    
            FutureTask<String> futureTask2 = new FutureTask<>(()->
                int i =0 ;
                while(i<100)
                    System.out.println("Callable线程2在执行:"+i++);
                return "线程2执行完了";
            );
    
            new Thread(futureTask).start();
            new Thread(futureTask2).start();
            System.out.println(futureTask.get());
            System.out.println(futureTask2.get());
        
    
    

三种实现都比较简单(算线程池的话是四种)。若是初中级面试,这么回答是没有问题的,若是面试高开,这么回答就不是很好了。那该怎么回答呢?
java中本质上线程的创建技术只有一种,就是利用Thread+Runnable接口来实现多线程,其他所有方式都是基于Thread+Runnable接口来改造而来,所以本质上就只有一种。为什么这么说呢,对于使用Runnable的实现方式应该没有意义,那我们就来聊一聊Thread和Callable吧,我们通过继承Thread时,需要重写run方法,实际上这个run方法就是Runnable的,进到Thread的源码就可以看到他也实现了Runnable。而对于Callable怎么说呢,通过Callable来实现多线程时,我们必须使用FutureTask类对Callable的对象进行包装,然后将FutureTask传递给Thread,这样才能够启动多线程。我们看下FutureTask的run方法就会发现,他其实调取的就是Callable的call方法,然后将返回值存取了,而FutureTask之所以有run方法,就是因为他的父类继承了接口Runnable。所以实现了Callable本质上也还是使用Runnable实现的类。其实还有线程池,线程池不过使用Runnable还是Callable其实都是一样也都是利用的Runnable。这里不做源码展示了,感性的小伙伴可以看这里:Java创建线程的方式只有一种:Thread+Runnable,笔者在这里详细分析了所有多线程的实现方式是怎么利用Thread+Runnable进行变化的。

同样的Callable同样也实现了Runnable接口:

👨‍🎓2.java中线程的真正状态

线程状态我们常说的有这几种:新建(new)、就绪(runnable)、运行(running)、阻塞(block)、死亡(dead)。一般理解这些状态时我们是下面这样的:

其实这些线程状态并不是java定义出的状态,而是我们根据线程的运行过程自己定义的线程状态。其实java也为线程定义了自己的状态值。在Thread中有一个state枚举类,就是定义了线程的状态共有六种,如下:

public enum State 
        /**
         * Thread state for a thread which has not yet started.
         */
        NEW,

        /**
         * Thread state for a runnable thread.  A thread in the runnable
         * state is executing in the Java virtual machine but it may
         * be waiting for other resources from the operating system
         * such as processor.
         */
        RUNNABLE,

        /**
         * Thread state for a thread blocked waiting for a monitor lock.
         * A thread in the blocked state is waiting for a monitor lock
         * to enter a synchronized block/method or
         * reenter a synchronized block/method after calling
         * @link Object#wait() Object.wait.
         */
        BLOCKED,

        /**
         * Thread state for a waiting thread.
         * A thread is in the waiting state due to calling one of the
         * following methods:
         * <ul>
         *   <li>@link Object#wait() Object.wait with no timeout</li>
         *   <li>@link #join() Thread.join with no timeout</li>
         *   <li>@link LockSupport#park() LockSupport.park</li>
         * </ul>
         *
         * <p>A thread in the waiting state is waiting for another thread to
         * perform a particular action.
         *
         * For example, a thread that has called <tt>Object.wait()</tt>
         * on an object is waiting for another thread to call
         * <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
         * that object. A thread that has called <tt>Thread.join()</tt>
         * is waiting for a specified thread to terminate.
         */
        WAITING,

        /**
         * Thread state for a waiting thread with a specified waiting time.
         * A thread is in the timed waiting state due to calling one of
         * the following methods with a specified positive waiting time:
         * <ul>
         *   <li>@link #sleep Thread.sleep</li>
         *   <li>@link Object#wait(long) Object.wait with timeout</li>
         *   <li>@link #join(long) Thread.join with timeout</li>
         *   <li>@link LockSupport#parkNanos LockSupport.parkNanos</li>
         *   <li>@link LockSupport#parkUntil LockSupport.parkUntil</li>
         * </ul>
         */
        TIMED_WAITING,

        /**
         * Thread state for a terminated thread.
         * The thread has completed execution.
         */
        TERMINATED;
    

这才是java给线程定义的官方状态,是6种,下面列些各个状态的区别:

  • NEW
    这是新生主状态也就是利用start方法创建完线程就是NEW状态
  • RUNNABLE
    线程在竞争CPU资源期间是属于可运行状态,同时线程运行时java也将其归结到了这里
  • WAITING
    当执行了wait方法以后,线程释放锁就会进入这个状态,这个状态下的线程必须被手动唤起,不能自动唤起(正常情况BLOCKED会自动唤起)。
  • BLOCKED
    锁竞争失败后线程进入阻塞状态,该状态下会自动唤起,无需手动干预
  • TIMED_WAITING
    执行sleep方法会进入这个状态,该状态下不会释放锁资源,会在休眠状态结束后直接进入RUNNABLE状态
  • TERMINATED
    当run方法执行结束时,线程就会进入该状态了也就是死亡了

👨‍🎓3.如何正确停止线程

java本身提供了停止线程的方式:可以使用stop方法,不过stop方法已经加上了Dreprected注解,也就是不太推荐使用的方法。那我们该如何停止线程呢?常用的方法有三种:

  • 1.通过Volatile来修饰boolean来实现线程停止
    如下所示,当flag变化时我们可以让线程停止。

    	public class TestThread 
        volatile static Boolean  flag = true;
    
    
        public static void main(String[] args) throws Exception
            FutureTask<String> futureTask = new FutureTask<>(()->
                int i =0 ;
                while(flag)
                    System.out.println("Callable线程1在执行:"+i++);
                return "线程1执行完了";
            );
    
            new Thread(futureTask).start();
            System.out.println(futureTask.get());
        
    
    
  • 2.使用Interrupt方法+阻断标志来实现退出线程
    每个线程默认都有一个阻断标志默认是false,interrupt方法就是改变阻断标志的方法,执行interrupt后线程的阻断标志就会变化为true,我们可以利用阻断标志的变化来停止线程,如下所示在线程5中将线程1的阻断标志更改,线程1利用阻断标志来退出线程也是ok的

    	public class TestThreadController 
    
        public static void main(String[] args) 
    
    
            // 线程1
            ThreadOne thread = new ThreadOne();
            thread.start();
    
            //线程5
            ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,1, 60,TimeUnit.SECONDS, new SynchronousQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.DiscardPolicy() );
            threadPoolExecutor.execute(()->
                int i =0;
                for(;;)
                    if(i<10)
                        System.out.println("线程5执行中i: "+i);
                        i++;
                    else
                        thread.interrupt();
                        break;
                    
    
                
            );
    
        
    
    
        class ThreadOne extends Thread
    
        @Override
        public void run()
            while(!Thread.currentThread().isInterrupted())
                System.out.println("线程1执行中");
            
        
    
    
    
  • 3.使用interrupt方法+异常catch来实现退出线程
    调用interrupt后,调用线程会抛出阻断异常,我们可以根据这个异常抛出然后进行结束线程,这种也是一种正确的停止线程方式

    	public class TestThread 
        volatile static  Boolean  flag = true;
    
        public static void main(String[] args) 
            Thread thread1 = new Thread()
                @Override
                public void run() 
                    while(true)
                        System.out.println("线程1在运行");
                        try 
                            Thread.sleep(1000);
                         catch (InterruptedException e) 
                            e.printStackTrace();
                            System.out.println("发生了阻断异常。。。。");
                            break;
                        
                    
                
            ;
    
            Thread thread2 = new Thread()
                @Override
                public void run() 
                    for (int i = 0; i < 100; i++) 
                        if(i==50)
                            System.out.println("################### 线程2试图阻断线程1 ###################");
                            thread1.interrupt();
                        else
                            System.out.println("线程2在运行:"+i);
                        
                    
                
            ;
    
            thread1.start();
            thread2.start();
        
    
    

👨‍🎓4.java中sleep和wait的区别

这两个方法的区别还是很大的,下面从各方面说下他们的区别

  • 1.sleep是Thread的静态方法可以直接用Thread.sleep()进行调用,wait是Object中的方法,需要使用对象进行调用
  • 2.sleep执行后线程是timed_wating状态,不会释放持有的锁,时间结束会自动被唤醒。wait执行后线程进入wating状态,释放持有的锁,需要手动被唤醒(使用notify,notifyall唤醒)
  • 3.sleep不持有锁时可以执行,wait方法必须在持有锁时才可以执行
    wait方法本质上会将持有的锁进行释放(只能持有synchronized),释放的过程就是更改对象的头部里的markword中存储的对象锁信息,若是未持有锁,在这里去更改自然就会报错了。

👨‍🎓5.并发编程的三大特性

这个点主要是知道并发时的三个需要面临的问题有哪三个:

  • 1.原子性
    原子性说的是一命令执行期间不会受其他线程影响,且执行结果要么成功要么失败。这也是我们最长说的问题,synchronized是可以解决这一问题的,其他的lock锁也是可以解决这一问题。
  • 2.可见性
    可见性说的多个线程同时操作一个变量时,其中一个对变量进行了改变,其他线程能否正常获取到变化后的值的问题,Volatile是可以解决这一问题的
  • 3.顺序性
    无论是java中的几即时编译器还是cpu都有可能对操作指令进行重拍,也就是根据我们写出来的程序,翻译出来的操作码和操作数可能与预想的不一样,这样也有可能产生线程安全问题。Volatile可以解决这一问题

👨‍🎓6.什么是CAS,有什么优缺点

CAS(compare and swap)它是一种乐观锁的实现原理,java中不仅提供了synchronized和lock来实现线程安全,还提供了一些工具类天然支持线程安全,比如常见的StringBuffer、HashTable等。此外java中还提供了一些使用CAS实现的Automic原子类来在多线程环境下使用:
AtomicInteger、AtomicLong、 AtomicBoolean。

  • 1.什么是CAS
    CAS(compare and swap)从字面上翻译CAS就是比较然后交换的意思,其实他的工作原理也就是这个样子,锁机制都是让多线程的操作变成串行化,而CAS却不是,他是先获取变量值,在需要执行变更操作时先去拿这个值与主内存的值进行比较,若是相等再将执行当前线程的操作,不等则需要重新获取然后再执行当前线程的操作,这就是CAS。必须要说的是CAS是一个原子操作,也就是说比较然后设置这个操作是不会被其他线程中断的,它线程安全,此外这种不使用锁来实现的同步机制也被称为乐观锁。相反的synchronized就是悲观锁了。

  • 2.CAS的工作机制
    了解了CAS,还必须要知道CAS是如何保证线程安全的,我们做个场景假设来模拟下CAS的工作流程,需要说的是这个流程是JDK8之前的,JDK8之后对CAS做了优化,但是这套机制还是适用的,JDK8只是将CAS操作变成了分段处理,每段的处理还是现在这个流程,JDK8具体的修改往下看会有介绍。下面先来假设下场景:假设有两个线程:线程一、线程二,正在同时修改AtomicInteger的值。主内存中AtomicInteger值是1。则会有如下场景发生:
    ①.线程一和线程二都拿到了主内存中的AtomicInteger的值是1。
    ②线程一想要修改AtomicInteger的值为2,修改之前先拿到自己工作内存中的1与主内存的1对比,发现相等后,将工作内存和主内存的AtomicInteger都改为了2.
    ③线程二此时却想要将AtomicInteger的值改为3,线程2则先拿着自己工作内存中存储的1去与工作内存中的2对比,发现不相等,不相等则不能设置,而是从新从主内存获取,获取后再次比较发现相等了,然后设置工作内存和主内存的值为3

    这就是CAS的工作机制的流程,因为CAS是原子操作故而保证了线程的安全。只要有一个线程在做CAS操作,那其他线程是不能进行打断的。

  • 3.JDK8对CAS机制的优化 和 LongAdder
    根据CAS的机制,我们可以发现当线程量十分多的时候,CAS的性能就会越来越低,因为CAS是原子操作,就会导致其他线程在不停的获取值,然后比较后发现不相等,再接着从新获取,就会陷入这样的恶性循环。因此在JDK8时对CAS机制进行了优化推出了LongAdder类,该类就是基于优化后的CAS实现的。那JDK8对CAS进行了怎样的优化呢?JDK8针对高并发场景提出了分段CAS和自动分段迁移的方式来提升高并发执行CAS时的性能。那这个分段CAS是个什么意思,自动分段迁移又是什么?来看下LongAdder的工作机制就会清楚了。先来做个场景假设有很多个线程在同时修改LongAdder的值,那么就会有如下场景发生。
    ①当发现有很多线程在进行CAS操作,致使很多线程出现空旋转的情况时,此时会保存一个已经计算出来的值作为base值,并且此时会创建一个cell数组,让一部分线程的计算结果存入一个cell中,这样就可以将所有线程分成好几部分来分开计算(分段CAS)。
    ②当有cell计算失败时,会将线程的操作自动迁移到其他cell中计算(自动分段迁移)。
    ③当所有线程都计算完毕后对base和cell进行合并计算得出最终结果。
    这样就会提升了CAS在高并发下的效率。

  • 4.为什么要对CAS进行优化
    根据前面假设的场景可以发现CAS在高并发的场景下会让大量线程出现空旋转的情况,从而出现影响性能的情况。因而在JDK8时才对CAS进行优化,新增了LongAdder类。LongAdder的实现机制就是分段CAS+自动分段迁移。这样就大大提高了在多线程场景下的效率,当然了若是线程量比较小的场景我们还是使用原子类AtomicInteger等类即可。无需使用LongAdder。若是了解JDK8中提供的流式操作的同学可能会比较熟悉这个场景,流式操作的底层也是会对流进行分段处理,这其实是一种很常见的并发处理思想,同时也多处用于提升处理效率。

  • 5.已经有锁了,为什么还要CAS机制
    前面已经说过,synchronized是一种悲观锁,CAS机制则被认为一种乐观锁。悲观锁可以支持代码块、方法级别的同步,自然也是可以在包装的情况下修改字段的值,而乐观锁主要强调的是对单一变量的修改,他们的侧重点不一样,并且在单一变量的修改场景使用悲观锁的代价太高,悲观锁所耗费的虚拟机性能要高出很多。所以才有了CAS的生存空间。

  • AtomicInteger使用示例
    下面只是一个假设的场景,主要是为了验证AtomicInteger的安全性,代码如下:

    import java.util.concurrent.atomic.AtomicInteger;
    
    /**
     * @author pcc
     * @version 1.0.0
     * @className TestThread
     * @date 2021-06-28 16:33
     */
    public class TestThread 
        static AtomicInteger atomicInteger = new AtomicInteger(0);
        public static void main(String[] args) throws InterruptedException
            for (int i1 = 0; i1 < 20000; i1++) 
                new Thread(() -> 
                    System.out.println(Thread.currentThread().getName()+"线程正在操作+1");
                    atomicInteger.addAndGet(1);
                ).start();
            
            Thread.sleep(1000);
            System.out.println(atomicInteger.toString());
        
    
    
    

👨‍🎓7.Contended注解有什么用

该注解是Java8中新增加的一个注解,主要应用场景就是在CAS中,比如CurrentHashMap的CounterCell和LongAdder中的Cell都是被该注解修饰的。那该注解到底是什么用处呢,其实该注解主要使用的应用原理是为了减少工作内存从主内存同步数据的频率,CAS在加锁过程中需要频繁的从工作内存中获取数据与主内存数据进行对比,这个过程是很耗费cpu性能的。而Contended注解就是为了减少这一过程,从而达到提升CAS效率的一个目的。那原理上Contended是怎么做的呢。Contentded将从主内存通过过来的数据补充7个假的数据(缓存行64字节一行,一个long是8位,所以补充7个long的假数据)这样就减少了一个缓存行中数据从主内存中同步数据的频率。从而实现了对CAS性能的提升,不过Contentded只是用来提升long类型存储和计算的效率。其他类型暂还不支持。

👨‍🎓8.java中四种引用类型有哪些

java中有强、软、弱、虚四种引用数据类型,详细了解可以参考笔者的一篇专门介绍引用的文章:java中的强引用、软引用、弱引用、引用用。他们的区别其实主要体现在两个方面,一个是创建方式,一个是回收方式,下面从这两个方面介绍下这四种引用数据类型。

  • 强引用
    我们通过正常new出来的对象无论是成员变量还是局部变量都是强引用(创建方式),强引用的回收完全依赖于可达性分析算法,当对象在GCRoots间有引用链时就不会被判定为垃圾,若无则会被判定垃圾,在下一次GC时进行回收(回收方式)

  • 软引用
    软引用的声明需要借助SoftReference来进行声明(创建方式),同时软引用会在jvm下一次GC时进行尝试回收,注意只是尝试回收并不会一定回收,真正呢能不能回收还是根据可达性分析算法来判定,可达依然不会回收(回收方式)。

  • 弱引用
    弱引用的声明需要借助WeakReference来进行声明(创建方式),弱引用的对象通常熬不过一次垃圾收集,jvm会在gc时对弱引用进行直接回收,不过不一定全部会回收,但会尝试回收且无需判定可达,直接判定为垃圾,典型的应用是java里的ThreadLocalMap的key就是弱引用(回收方式)

  • 虚引用
    虚引用的创建同样需要依赖第三者类,依赖的是PhantomReference,同时还需要为虚引用提供一个引用队列来存储虚引用。(创建方式)此外虚引用创建即销毁,是查询不到该引用的存在的,据说这种引用类型就是为了观察对象的创建和销毁过程而存在的实际没啥用。(回收方式)

👨‍🎓9.ThreadLocal的内存泄露问题只有value吗?

这个问题一般都会说value的内存泄露问题,其实这个问题可以聊一聊key的泄露和value的泄露。先来回忆下ThreadLocal的实现原理:
实际上我们在线程内部使用ThreadLocal存储对象时,他的对象时存储在ThreadLocalMap中的,事实上每个线程都有一个独立的ThreadLocalMap,每个线程有且仅有一个ThreadLocalMap,无论有多少ThreadLocal操作数据都会被存入到线程中这个独有的ThreadLocalMap,且每个ThreadLocal其实只能存储一个值,因为ThreadLocal会被作为key存放到ThreadLocalMap中,key的位置采用hash值进行计算,key的话就是ThreadLocal,value的话就是我们存入的值,key值得说的是他是一个weakreference,在下一次GC时会被回收。假设有如下一个场景:两个ThreadLocal,一个线程内部的ThreadLocalMap存储了两个ThreadLocal作为key的entry。我们来分析下key和value的内存泄露问题

  • key的泄露问题
    key的泄露其实并不会很严重,java已经使用WeakReference来规避了这个场景,所以在使用弱引用作为key其实就已经解决了这个问题,那这个问题是怎么产生的呢?当ThreadLocal失去了引用之后,理论上他就不可达了,会被判定为垃圾。但是此时ThreadLocalMap仍然可达,所以ThreadLocal也就不会被回收,因为ThreadLocalMap的key是ThreadLocal,所以ThreadLocal仍然有一条引用链存在,这就导致了ThreadLocal并不会被正常回收,这一过程将会持续到线程结束才会被正常回收。java解决这个问题就是引用了弱引用来作为key就解决了这个问题。当ThreadLocal失效以后,key因为是弱引用所以被收回,这样就避免了key的内存泄露问题。那value呢?此时key内存泄露,value自然是内存泄露的。
  • value的泄露问题
    要解决value的内存泄露问题,其实需要我们手动处理,也就是ThreadLocal使用完毕之后手动调用他的remove方法即可,他会删除entry,从而避免了内存泄露。
  • ThreadLocal的实际应用:ReentrantReadWriteLock
    ReentrantReadWriteLock中的写锁是互斥锁和普通锁区别不大,但是读锁是共享锁,而且是可重入锁,利用AQS的state显然满足不了这个实现。事实上state高位是用以记录读锁的持有状态的(前16位)低位(后16位)是记录写锁的,当一个线程想要进行对读操作重复进行加锁时,就会发现无法知道自己重入了多少次(持有锁的线程会很多,无法根据持有锁的线程判断),此时java采用每个线程都用Threadlocal维护自己持有锁的次数,从而解决了读锁的重入问题。

👨‍🎓10.java中的锁分类

这个问题答案就很多了,可以从各个角度说下锁的分类

  • 从锁的重量来划分
    重量级锁(1.6之前):synchronized
    jdk1.6之前synchronized实现完全是依赖对象头中的markword中存储的锁来判断加锁,一旦加锁成功所有线程均需等待。
    轻量级锁:lock
    lock是1.5引入,底层采用AQS的模式来进行加锁,AQS地产是CAS机制,并不会直接上锁,而是对数据进行一个判断后再进行操作,其他线程过来并不会第一时间进行上锁,大家都是先沟通再操作。
  • 从锁是否可重入来划分
    可重入锁:ReentrantLock
    因为底层是AQS,借助了AQS中的state来标注加锁状态,0未加锁,大于0则是加锁,从而实现可重入
    不可重入锁:synchrozed
    锁一旦加上则其他任何线程禁止执行
  • 从锁的公平性来划分
    公平锁:创建ReentrantLock时传入true则是公平锁
    公平锁加锁时会维护一个队列,等待线程会进入队列排队,加锁是根据队列中先入先加来加锁的,公平锁可以解决线程饥饿问题
    非公平锁:其余场景包括synchronized都是非公平锁
    非公平锁可能会造成线程饥饿
  • 从锁的实现原理划分
    乐观锁:使用AQS实现的如:Reentrant是乐观锁
    悲观锁:1.6之前的Synchrozed
    因为1.6只有java对synchronized进行了优化,他的底层开始是偏向锁,偏向锁底层是CAS,也是一种乐观锁的实现。
  • 从锁的互斥性来划分
    互斥锁:ReentrantReadWriteLock.writeLock 写锁
    写锁排期其他线程的读行为和写行为,对其他线程互斥
    共享锁:ReentrantReadWriteLock.readLock 读锁
    读锁不排斥其他线程的读行为,但不能写,是一种共享锁

👨‍🎓11.Synchronized在1.6的优化

java1.6开始对Synchronized进行了三块优化,这也就是上面为什么说java1.6之前synchronized是重量级锁是悲观锁的原因,因为1.6做了优化之后这个已经变了。

  • 锁消除
    1.6开始java中JIT会帮助判断加锁的场景是否真有并发场景产生,若是判断这个场景没有并发,JIT则会将加锁取消,这个场景会存在误判的可能。
  • 锁膨胀
    锁膨胀就是将锁的范围进行扩大,一般是指JIT在编译Java代码时,将多个连续的锁操作合并成一个更大的锁操作。这种优化可以避免在循环中反复进行锁操作,从而减少锁竞争的开销,提高程序的性能。
  • 锁粗化
    1.6之后使用Synchronized会有这样一个过程:无锁–>偏向锁–>轻量级锁–>重量级锁,锁在这个过程中会不断进行粗化,越来越重知道最后使用synchronized原始的加锁方式,这个过程被称为锁粗化的过程。粗粗化过程:初始状态是不加锁,当有一个线程过来尝试加锁时会进行升级到偏向锁,偏向锁底层是CAS,当有另一个线程也尝试进行加锁时,会先判断加锁线程是不是持有偏向锁的线程,如果是可以继续执行,不是的话,则会触发锁升级,此时会升级为轻量级锁。synchronized为轻量级锁时表示锁存在多个资源在进行竞争,当竞争不到锁的时候,线程会进入自旋状态,也就是我们常说的自旋锁,所以轻量级锁也叫自旋锁(适应性自旋锁),这个状态下未抢占到锁的线程会进入空旋转,而不是直接进入到阻塞状态,当锁旋转一定周期后(周期初始是10,会自动调整),仍然获取不到锁,会对自旋进行调整,然后对锁进行升级,此时锁就会进入重量级锁的状态,重量级锁使用的就是java1.6之前的实现方式,只有一个线程能获取到锁,其他线程阻塞。

    上图是对象的一个简略信息,锁信息便是存储在对象头中,下图是锁的几种状态在对象头中的体现。

👨‍🎓12.Synchronized实现的原理

上面已经介绍了synchronized的原理,这里就不重复说了

👨‍🎓13.什么是AQS

AQS(AbstractQueuedSynchronizer)抽象队列同步器,他是JUC中提供的一个工具类,可以帮助我们实现加锁。java中的ReentrantLock、ReentrantReadWriteLock、ThreadPoolExecutor都使用了AQS来实现自己的锁机制,举一个ReentrantLock的例子来说明AQS实现锁的过程。AQS中使用CAS 修饰的int类型的变量state来标识锁的

大厂面试快问快答,10分钟搞定MySQL夺命20问,你都能接住吗?

号外号外!《死磕 Java 并发编程》系列连载中,大家可以关注一波:

「死磕 Java 并发编程05」阿里面试失败后,一气之下我图解了Java中18把锁

「死磕 Java 并发编程04」说说Java Atomic 原子类的实现原理

「死磕 Java 并发编程03」阿里二面,面试官:说说 Java CAS 原理?

「死磕 Java 并发编程02」面试官:说说什么是 Java 内存模型(JMM)?

「死磕 Java 并发编程01」10张图告诉你Java并发多线程那些破事

先看下目录:

1 说说MySQL 的基础架构图

2 一条SQL查询语句在MySQL中如何执行的?

3 日常工作中你是怎么优化SQL的?

4 怎么看执行计划(explain),如何理解其中各个字段的含义?

5 关心过业务系统里面的sql耗时吗?统计过慢查询吗?对慢查询都怎么优化过?

6 聚集索引与非聚集索引的区别

7 为什么要用 B+ 树,为什么不用普通二叉树?

8 Hash 索引和 B+ 树索引区别是什么?你在设计索引是怎么抉择的?

9 什么是最左前缀原则?什么是最左匹配原则?

10 索引不适合哪些场景?

11 索引有哪些优缺点?

12 MySQL 遇到过死锁问题吗,你是如何解决的?

13 说说数据库的乐观锁和悲观锁是什么以及它们的区别?

14 MVCC 熟悉吗,知道它的底层原理?

15 MySQL事务得四大特性以及实现原理

16 事务的隔离级别有哪些?MySQL的默认隔离级别是什么?

17 什么是幻读,脏读,不可重复读呢?

18 MySQL数据库cpu飙升的话,要怎么处理呢?

19 MYSQL的主从延迟,你怎么解决?

20 如果让你做分库与分表的设计,简单说说你会怎么做? 

在夺命 20 问要开始了之前推荐一份阿里大佬总结的面试资料,覆盖了数据结构、计算机网络、操作系统、 Java 核心知识、面经等,对编程学习帮助很大:

看完阿里师兄总结的Java知识笔记,秒杀一线大厂面试(283 页,含下载方式)

数据库架构

说说MySQL 的基础架构图

给面试官讲一下 MySQL 的逻辑架构,有白板可以把下面的图画一下。

图片来源于网络

 

Mysql逻辑架构图主要分三层: (1)第一层负责连接处理,授权认证,安全等等 (2)第二层负责编译并优化SQL (3)第三层是存储引擎。

一条SQL查询语句在MySQL中如何执行的?

  • 先检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限会先查询缓存(MySQL8.0 版本以前)。

  • 如果没有缓存,分析器进行词法分析,提取 sql 语句中 select 等关键元素,然后判断 sql 语句是否有语法错误,比如关键词是否正确等等。

  • 最后优化器确定执行方案进行权限校验,如果没有权限就直接返回错误信息,如果有权限就会调用数据库引擎接口,返回执行结果。

SQL 优化

日常工作中你是怎么优化SQL的?

可以从这几个维度回答这个问题:

1,优化表结构

(1)尽量使用数字型字段

若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。 这是因为引擎在处理查询和连接时会逐个比较字符串中每一个字符,而对于数字型而言只需要比较一次就够了。

(2)尽可能的使用 varchar 代替 char

变长字段存储空间小,可以节省存储空间。

(3)当索引列大量重复数据时,可以把索引删除掉

比如有一列是性别,几乎只有男、女、未知,这样的索引是无效的。

2,优化查询

  • 应尽量避免在 where 子句中使用!=或<>操作符

  • 应尽量避免在 where 子句中使用 or 来连接条件

  • 任何查询也不要出现select *

  • 避免在 where 子句中对字段进行 null 值判断

3,索引优化

  • 对作为查询条件和 order by的字段建立索引

  • 避免建立过多的索引,多使用组合索引

怎么看执行计划(explain),如何理解其中各个字段的含义?

在 select 语句之前增加 explain 关键字,会返回执行计划的信息。

(1)id 列:是 select 语句的序号,MySQL将 select 查询分为简单查询和复杂查询。

(2)select_type列:表示对应行是是简单还是复杂的查询。

(3)table 列:表示 explain 的一行正在访问哪个表。

(4)type 列:最重要的列之一。表示关联类型或访问类型,即 MySQL 决定如何查找表中的行。 从最优到最差分别为:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL

(5)possible_keys 列:显示查询可能使用哪些索引来查找。

(6)key 列:这一列显示 mysql 实际采用哪个索引来优化对该表的访问。

(7)key_len 列:显示了mysql在索引里使用的字节数,通过这个值可以算出具体使用了索引中的哪些列。

(8)ref 列:这一列显示了在key列记录的索引中,表查找值所用到的列或常量,常见的有:const(常量),func,NULL,字段名。

(9)rows 列:这一列是 mysql 估计要读取并检测的行数,注意这个不是结果集里的行数。

(10)Extra 列:显示额外信息。比如有 Using index、Using where、Using temporary等。

关心过业务系统里面的sql耗时吗?统计过慢查询吗?对慢查询都怎么优化过?

我们平时写Sql时,都要养成用explain分析的习惯。慢查询的统计,运维会定期统计给我们

优化慢查询思路:

  • 分析语句,是否加载了不必要的字段/数据

  • 分析 SQL 执行句话,是否命中索引等

  • 如果 SQL 很复杂,优化 SQL 结构

  • 如果表数据量太大,考虑分表

索引

聚集索引与非聚集索引的区别

可以按以下四个维度回答:

(1)一个表中只能拥有一个聚集索引,而非聚集索引一个表可以存在多个。

(2)聚集索引,索引中键值的逻辑顺序决定了表中相应行的物理顺序;非聚集索引,索引中索引的逻辑顺序与磁盘上行的物理存储顺序不同。

(3)索引是通过二叉树的数据结构来描述的,我们可以这么理解聚簇索引:索引的叶节点就是数据节点。而非聚簇索引的叶节点仍然是索引节点,只不过有一个指针指向对应的数据块。

(4)聚集索引:物理存储按照索引排序;非聚集索引:物理存储不按照索引排序;

为什么要用 B+ 树,为什么不用普通二叉树?

可以从几个维度去看这个问题,查询是否够快,效率是否稳定,存储数据多少,以及查找磁盘次数,为什么不是普通二叉树,为什么不是平衡二叉树,为什么不是B树,而偏偏是 B+ 树呢?

(1)为什么不是普通二叉树?

如果二叉树特殊化为一个链表,相当于全表扫描。平衡二叉树相比于二叉查找树来说,查找效率更稳定,总体的查找速度也更快。

(2)为什么不是平衡二叉树呢?

我们知道,在内存比在磁盘的数据,查询效率快得多。如果树这种数据结构作为索引,那我们每查找一次数据就需要从磁盘中读取一个节点,也就是我们说的一个磁盘块,但是平衡二叉树可是每个节点只存储一个键值和数据的,如果是B树,可以存储更多的节点数据,树的高度也会降低,因此读取磁盘的次数就降下来啦,查询效率就快啦。

(3)为什么不是 B 树而是 B+ 树呢?

B+ 树非叶子节点上是不存储数据的,仅存储键值,而B树节点中不仅存储键值,也会存储数据。innodb中页的默认大小是16KB,如果不存储数据,那么就会存储更多的键值,相应的树的阶数(节点的子节点树)就会更大,树就会更矮更胖,如此一来我们查找数据进行磁盘的IO次数有会再次减少,数据查询的效率也会更快。

B+ 树索引的所有数据均存储在叶子节点,而且数据是按照顺序排列的,链表连着的。那么 B+ 树使得范围查找,排序查找,分组查找以及去重查找变得异常简单。

Hash 索引和 B+ 树索引区别是什么?你在设计索引是怎么抉择的?

  • B+ 树可以进行范围查询,Hash 索引不能。

  • B+ 树支持联合索引的最左侧原则,Hash 索引不支持。

  • B+ 树支持 order by 排序,Hash 索引不支持。

  • Hash 索引在等值查询上比 B+ 树效率更高。

  • B+ 树使用 like 进行模糊查询的时候,like 后面(比如%开头)的话可以起到优化的作用,Hash 索引根本无法进行模糊查询。

什么是最左前缀原则?什么是最左匹配原则?

最左前缀原则,就是最左优先,在创建多列索引时,要根据业务需求,where 子句中使用最频繁的一列放在最左边。

当我们创建一个组合索引的时候,如 (a1,a2,a3),相当于创建了(a1)、(a1,a2)和(a1,a2,a3)三个索引,这就是最左匹配原则。

索引不适合哪些场景?

  • 数据量少的不适合加索引

  • 更新比较频繁的也不适合加索引 = 区分度低的字段不适合加索引(如性别)

索引有哪些优缺点?

(1) 优点:

  • 唯一索引可以保证数据库表中每一行的数据的唯一性

  • 索引可以加快数据查询速度,减少查询时间

(2)缺点:

  • 创建索引和维护索引要耗费时间

  • 索引需要占物理空间,除了数据表占用数据空间之外,每一个索引还要占用一定的物理空间

  • 以表中的数据进行增、删、改的时候,索引也要动态的维护。

MySQL 遇到过死锁问题吗,你是如何解决的?

遇到过。我排查死锁的一般步骤是酱紫的:

(1)查看死锁日志 show engine innodb status; (2)找出死锁Sql (3)分析sql加锁情况 (4)模拟死锁案发 (5)分析死锁日志 (6)分析死锁结果

说说数据库的乐观锁和悲观锁是什么以及它们的区别?

(1)悲观锁:

悲观锁她专一且缺乏安全感了,她的心只属于当前事务,每时每刻都担心着它心爱的数据可能被别的事务修改,所以一个事务拥有(获得)悲观锁后,其他任何事务都不能对数据进行修改啦,只能等待锁被释放才可以执行。

(2)乐观锁:

乐观锁的“乐观情绪”体现在,它认为数据的变动不会太频繁。因此,它允许多个事务同时对数据进行变动。

实现方式:乐观锁一般会使用版本号机制或CAS算法实现。

MVCC 熟悉吗,知道它的底层原理?

MVCC (Multiversion Concurrency Control),即多版本并发控制技术。

MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

事务

MySQL事务得四大特性以及实现原理

  • 原子性: 事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。

  • 一致性: 指在事务开始之前和事务结束以后,数据不会被破坏,假如A账户给B账户转10块钱,不管成功与否,A和B的总金额是不变的。

  • 隔离性: 多个事务并发访问时,事务之间是相互隔离的,即一个事务不影响其它事务运行效果。简言之,就是事务之间是进水不犯河水的。

  • 持久性: 表示事务完成以后,该事务对数据库所作的操作更改,将持久地保存在数据库之中。

事务的隔离级别有哪些?MySQL的默认隔离级别是什么?

  • 读未提交(Read Uncommitted)

  • 读已提交(Read Committed)

  • 可重复读(Repeatable Read)

  • 串行化(Serializable)

Mysql默认的事务隔离级别是可重复读(Repeatable Read)

什么是幻读,脏读,不可重复读呢?

事务A、B交替执行,事务A被事务B干扰到了,因为事务A读取到事务B未提交的数据,这就是脏读。

在一个事务范围内,两个相同的查询,读取同一条记录,却返回了不同的数据,这就是不可重复读。

事务A查询一个范围的结果集,另一个并发事务B往这个范围中插入/删除了数据,并静悄悄地提交,然后事务A再次查询相同的范围,两次读取得到的结果集不一样了,这就是幻读。

实战

MySQL数据库cpu飙升的话,要怎么处理呢?

排查过程:

(1)使用top 命令观察,确定是mysqld导致还是其他原因。 (2)如果是mysqld导致的,show processlist,查看session情况,确定是不是有消耗资源的sql在运行。 (3)找出消耗高的 sql,看看执行计划是否准确, 索引是否缺失,数据量是否太大。

处理:

(1)kill 掉这些线程(同时观察 cpu 使用率是否下降), (2)进行相应的调整(比如说加索引、改 sql、改内存参数) (3)重新跑这些 SQL。

其他情况:

也有可能是每个 sql 消耗资源并不多,但是突然之间,有大量的 session 连进来导致 cpu 飙升,这种情况就需要跟应用一起来分析为何连接数会激增,再做出相应的调整,比如说限制连接数等

MYSQL的主从延迟,你怎么解决?

主从复制分了五个步骤进行:

图片来源于网络

 

  • 步骤一:主库的更新事件(update、insert、delete)被写到binlog

  • 步骤二:从库发起连接,连接到主库。

  • 步骤三:此时主库创建一个binlog dump thread,把binlog的内容发送到从库。

  • 步骤四:从库启动之后,创建一个I/O线程,读取主库传过来的binlog内容并写入到relay log

  • 步骤五:还会创建一个SQL线程,从relay log里面读取内容,从Exec_Master_Log_Pos位置开始执行读取到的更新事件,将更新内容写入到slave的db

主从同步延迟的原因

一个服务器开放N个链接给客户端来连接的,这样有会有大并发的更新操作, 但是从服务器的里面读取binlog的线程仅有一个,当某个SQL在从服务器上执行的时间稍长 或者由于某个SQL要进行锁表就会导致,主服务器的SQL大量积压,未被同步到从服务器里。这就导致了主从不一致, 也就是主从延迟。

主从同步延迟的解决办法

  • 主服务器要负责更新操作,对安全性的要求比从服务器要高,所以有些设置参数可以修改,比如sync_binlog=1,innodb_flush_log_at_trx_commit = 1 之类的设置等。

  • 选择更好的硬件设备作为slave。

  • 把一台从服务器当度作为备份使用, 而不提供查询, 那边他的负载下来了, 执行relay log 里面的SQL效率自然就高了。

  • 增加从服务器喽,这个目的还是分散读的压力,从而降低服务器负载。

如果让你做分库与分表的设计,简单说说你会怎么做?

分库分表方案:

  • 水平分库:以字段为依据,按照一定策略(hash、range等),将一个库中的数据拆分到多个库中。

  • 水平分表:以字段为依据,按照一定策略(hash、range等),将一个表中的数据拆分到多个表中。

  • 垂直分库:以表为依据,按照业务归属不同,将不同的表拆分到不同的库中。

  • 垂直分表:以字段为依据,按照字段的活跃性,将表中字段拆到不同的表(主表和扩展表)中。

常用的分库分表中间件:

  • sharding-jdbc

  • Mycat

分库分表可能遇到的问题

  • 事务问题:需要用分布式事务啦

  • 跨节点Join的问题:解决这一问题可以分两次查询实现

  • 跨节点的count,order by,group by以及聚合函数问题:分别在各个节点上得到结果后在应用程序端进行合并。

  • 数据迁移,容量规划,扩容等问题

  • ID问题:数据库被切分后,不能再依赖数据库自身的主键生成机制啦,最简单可以考虑UUID

  • 跨分片的排序分页问题

好了,夺命 20 问看完了,你能全部接住吗?

另外我把大学和工作中用的经典电子书库(包含数据结构、操作系统、C++/C、网络经典、前端编程经典、Java相关、程序员认知、职场发展)、面试找工作的资料汇总都打包放在这了,这套资源可不是一般那种网上找的资源,是伴随我从学生一路到职场,非常宝贵!

已经打包好了:保姆级整理,进大厂前必看的经典编程书单(含下载方式)

最后:

大家学会了吗?收藏等于白嫖,点赞才是真爱,雷小帅感谢大家~~~

作者:雷小帅

推荐一个Github 开源项目,『Java八股文』Java面试套路,领取 10G 免费学习资料,Java进阶学习,打破内卷拿大厂Offer,升职加薪!

作者简介:

☕读过几年书:华中科技大学硕士毕业;
😂浪过几个大厂:华为、网易、百度……
😘一直坚信技术能改变世界,愿保持初心,加油技术人!

微信搜索公众号【爱笑的架构师】,关注这个对技术有追求且有趣的打工人。

以上是关于java面试八股文之------Java并发夺命23问的主要内容,如果未能解决你的问题,请参考以下文章

Java~大厂面试八股文~强烈推荐视频

大厂面试快问快答,10分钟搞定MySQL夺命20问,你都能接住吗?

大厂面试快问快答,10分钟搞定MySQL夺命20问,你都能接住吗?

大厂面试快问快答,10分钟搞定MySQL夺命20问,你都能接住吗?

大厂面试快问快答,10分钟搞定MySQL夺命20问,你都能接住吗?

《我想进大厂》之Java基础夺命连环16问