Java学习之从“底”学起——JVM构造、GC垃圾回收、多线程、异常处理、集合框架、IO体系

2021年9月6日 5点热度 0条评论 来源: MrSuper_cat

临近秋招,许多小伙伴也开始狂刷面试题,总结面试考点,希望可以在今年这个不太一般的秋天去到心仪的公司。在这里,我来总结一下最近刷Java底层试题以及看面试视频的题型。
我的理解是,我们在回答Java方面的知识时,不仅仅是能达到“会用”,我们更要理解其原理是怎么实现的,理解了底层实现,才能决定以后在编程路上走到什么地步,所有语言大同小异,理解了设计思想,什么语言都是一样的。(以下图片全部源自网络)

文章目录

谈谈你对Java的理解

Java是什么?如果面试官问了你这个话题,该怎么作答呢?Java可谓是包罗万象,从最初的Sysytem.out.println("hello world"),到类与对象,再到文件、线程、异常处理,乃至Spring、SpringBoot、SpringCloud。这些都是Java,我们似乎被这个话题迷昏了眼睛,一时说不上来,该从那儿开始,这里,我们要做一个Java的框架,将所学过的知识串起来,这样即时面对范围这么庞大的问题,我们也可以知道该怎么回答了。

平台无关性

Java为什么流行,很大一部分原因就是因为其平台无关性,优越的跨平台性能使我们无需关注如何去调用系统指令为我们服务,不管是Linux、MAC OS、WINDOWS乃至其他,只要有了JVM,一切都无需我们去想,只需要一份class文件,JVM就会给我们转化为各个平台上的机器码然后运行。

基本指令

  1. 编译:在有Java环境的机器上,我们可以使用javac将Java源码编译成.class文件(这里需要注意,我们的源码如果语法语义错误,是不会编译成功的),之后供Java虚拟机去运行。
  2. 运行:JVM将我们的.class文件通过java命令加载到JVM中运行。
  3. 反编译:在我们拥有一份.class文件后,我们可以通过反编译,执行javap -c的指令反编译成我们认识的语言。

为什么我们不直接将Java源码转化成该平台的机器码

为什么要转化一个中间文件.class文件?通过JVM直接转化成该平台机器码不是更省事儿吗?原因如下,上文提到,我们在进行.class文件编译时,需要对Java源码进行检查,通过后才可以转化,转化成功后就表明语法没问题,这个.class文件就可以拿到任意一个平台去执行,但是如果没有这一步,我们刚在Linux运行了Java文件,转头相同的文件在Win上执行,还得再检查一遍,更加浪费了时间以及JVM的性能。
平台复用性:既然Java虚拟机有这么好的转化机制,我们也可以将其他语言转化为字节码文件,交给Java虚拟机去执行,这样,就符合了计算机系统中复用的理念。

Java反射

反射:在运行状态下,我们可以获取到任意一个类的属性和方法,也可以获取到任意一个对象的方法和属性,这种动态获取类信息以及动态调用对象的机制称为反射。
举个栗子

package com.app.demo

public class People{ 
	private String name;
	
	public String sayHello(String name){ 
		return name;
	}
	
	private String sayHi(String name){ 
		return name;
	}
}

package com.app.test

public class toTest{ 
	public static void main(String[] args){ 
	 Object x = Class.forName("com.app.demo.People");//获取字节码文件对象
	 People people = (PeoPle)x.newInstence();//获取实例对象
	 //获取私有方法
	 Method sayHi = x.getDeclaredMethod("sayHi",String.class);//传入要获取的方法名以及其参数列表的参数类型.
	 sayHi.setAccssible(true);//执行了这个方法才可以使用私有方法
	 Object str = sayHi.invoke(people,"Tom");//传入对象实例以及参数
	 //获取公有方法
	 Method sayHello = x.getMethod("sayHello",String.class);
	 Object str1 = sayHello.invoke(people,"Tom");
	 //获取私有属性
	 Flied name = x.getDeclaredFlied("name");
	 name.setAccessible(true);
	 name.set(people,"Tom");
	}
}

注意事项️:
我们在获取私有属性/方法时,必须要setAccessible(true)才可以获取到方法。
getDeclaredFlied([MethodName],[参数类型])可以获取全部方法,但是无法获取通过继承得到的方法。
getMethod([MethodName],[参数类型])无法获取私有方法,但是可以获取继承的到的方法。

类从编译到执行的过程

知道了Java反射的含义以及用法之后,我们再来看看类是如何从编译到运行的。
我们的类通过javac编译成.class文件之后是,还需要加载到JVM中执行,在这期间,会经过以下几个部分,我们先来看一下。

.class文件会经过一个ClassLoader(类加载器)将.class文件转化成Class 字节码文件对象,再通过JVM内存结构(Runtime Data Area)生成对象实例,然后通过Execution Engine将其解释称机器能识别的命令,期间可能会调用Native InterFace调用本地接口来调用本地方法(融合不同语言的原生方法为Java所用,这里的方法会在Native Method Stack中注册)之后提交命令到OS,让其执行。

ClassLoader是什么

ClassLoader(类加载器)负责将.class文件加载到JVM内存中,ClassLoader会寻找.class文件并将其转化成Class字节码对象文件。ClassLoader共有四种,分别为

  • BootStrapClassLoader:C++编写,在JVM启动时启动,负责加载Java核心库
  • ExtClassLoader:负责加载Java扩展库,会加载如ext/lib/jre文件夹下的扩展库
  • AppClassLoader:负责加载本程序的.class文件,加载classpath下的文件。
  • 自定义ClassLoader:可以加载自定义的.class文件,这里的class文件可以是本机的,也可以是网络上的,我们可以通过继承ClassLoader类,并重写其findClass(String name)方法并在其中执行defineClass(byte[],off,len),该方法会返回Class字节码对象。
    举个栗子(自定义ClassLoader)
class wail{ 
	static{ 
	System.out.println("hello wail");
	}
}

package com.interview.javabasic.reflect;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;

public class MyClassLoader extends ClassLoader { 
    private String path;
    private String classLoaderName;

    public MyClassLoader(String path, String classLoaderName) { 
        this.path = path;
        this.classLoaderName = classLoaderName;
    }

    //用于寻找类文件
    @Override
    public Class findClass(String name) { 
        byte[] b = loadClassData(name);
        return defineClass(name, b, 0, b.length);
    }

    //用于加载类文件
    private byte[] loadClassData(String name) { 
        name = path + name + ".class";
        InputStream in = null;
        ByteArrayOutputStream out = null;
        try { 
            in = new FileInputStream(new File(name));
            out = new ByteArrayOutputStream();
            int i = 0;
            while ((i = in.read()) != -1) { 
                out.write(i);
            }
        } catch (Exception e) { 
            e.printStackTrace();
        } finally { 
            try { 
                out.close();
                in.close();
            } catch (Exception e) { 
                e.printStackTrace();
            }
        }
        return out.toByteArray();
    }
}


package com.interview.javabasic.reflect;

public class ClassLoaderChecker { 
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException { 
        MyClassLoader m = new MyClassLoader("/Users/baidu/Desktop/", "myClassLoader");
        Class c = m.loadClass("Wali");
        Object w = c.newInstence();//调用该方法才可以执行静态代码块
    }
}

以上通过重写findClass()方法,先获取到文件,之后使用字节流将其传入defineClass()中构建字节码对象,之后返回字节码对象。

ClassLoader的双亲委派机制

我们在查看ClassLoader的源码时,发现其中有一个parent参数。

说明ClassLoader也是有父类的,结合我们上面提到的四种ClassLoader,不难想出这四种有着某种关系,这里可以告诉大家是一种包含关系。我们可以借助上面的例子,来看一下具体是什么关系。

这里很清楚的表明了关系,从上而下依次是
BootStrapClassLoader、ExtClassLoader、AppClassLoader、自定义ClassLoader。(这里的null是BootStrapClassLoader,因为其是C++编写,已经嵌入到了JVM的核心代码中,所以这里显示null。)
我们再来看ClassLoader源码中最重要的方法loadClass()

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    { 
        synchronized (getClassLoadingLock(name)) { 
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) { 
                long t0 = System.nanoTime();
                try { 
                    if (parent != null) { 
                        c = parent.loadClass(name, false);
                    } else { 
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) { 
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) { 
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) { 
                resolveClass(c);
            }
            return c;
        }
    }

从这里可以看出,当我们在加载一个class文件时,ClassLoader会先看自己有没有加载该文件,如果没有,则向上一级去询问,一层一层,直到BootStrapClassLoader,如果都没有,则由顶级ClassLoader尝试加载class文件(在自己管辖的文件范围内),此时还是加载不到的话,就返回null,由下一级尝试加载,直到加载到或者到了自定义ClassLoader还没加载到就报ClassNotFoundException。
总结:自下而上查看是否加载,自上而下尝试加载
问题:为什么要实现该机制? 因为在整个JVM中,我们只需要一份class字节码对象,好比我们加载System这个系统静态类,这个类已经在BootStrapClassLoader中被加载了,如果我们写了一个同名的class文件,就会先去加载这个,顶替了系统设定的这个,就会出现不安全的隐患。

类的加载方式之——new、forName与loadClass

说到类的加载,我们首先会想到new,这是一种隐式加载法,它隐式的调用了ClassLoader并且会返回一个对象实例。
其次,我们在上边已经实现了forName和loadClass的方法,关于前一种和后一种的区别,这里就要说一下类装载的的步骤了。
一、加载:通过调用loadClass()方法通过class名来找到.class文件,将二进制字节流转化为字节码对象Class<'T>
二、链接
1、检查:检查加载的class文件对象的正确性和安全性。
2、准备:为该对象分配存储空间以及设置该类的初始变量(ps:这里是设置该变量类型初始的值,比如int初始值是0,long是-1L,这里是这种初始值)
3、解析:(可选)JVM将常量池中的符号引用转化为直接引用。
三、初始化:执行类变量赋值,执行静态代码块
区别:forName执行时会直接进行一二三步,到初始化完成,这里我们用常见的加载JDBC驱动来说,查JDBC驱动的源码可知

加载驱动是要执行静态代码块的,所以forName是执行完第三步。
由上面我们自定义ClassLoader可以知道,如果我们没有使用newInstence方法来实例化这个对象的话,静态块是不会执行的,并且从该类传入的参数我们也可以知道loadClass(String name, boolean resolve),第二个参数是决定要不要执行链接操作,而在loadClass的源码中我们可以看到该值默认传入false。

问题:为什么要分loadClass和forName
俗话说的好,存在即合理,像加载mysql驱动时,我们要执行到静态块,所以选用forName。而在Spring中,Spring为我们管理着许多的bean,Spring为了快速,将许多的类只加载到了第一步,省去了初始化的时间,这样在我们真正调用某个类的时候,Spring就会为我们初始化。

JVM的构造

说到JVM,这个模型堪称神作,有了它,我们可以不必像C一样每次运行完手动垃圾回收,我们也规避了指针带来的风险,同时还可以进行多线程的实现。

JVM内存模型共分五个区域,其中线程独享的有 程序计数器、虚拟机栈、本地方法栈线程共享的有方法区、堆

  • 程序计数器
    1、程序计数器是用来记录当前执行字节码的行号的(行号是一个逻辑地址,不是真实地址)
    2、字节码解释器通过改变程序计数器的行号来执行下一条字节码指令。
    3、各个线程通过程序计数器来记住当前执行的进度,各个线程在切换之后还能记住自己当前执行到哪儿就是这个的功劳。
    4、程序计数器对Java方法进行计数的时候会显示行号,而对本地方法进行计数的时候会显示undefined
    5、程序计数器不会发生内存泄露
  • 虚拟机栈
    1、虚拟机栈是每个线程独有的,它通过将每个方法生成一个个栈帧,通过压栈弹栈的方式来执行各个方法。
    2、栈帧由局部变量表、操作数栈、动态链接、方法出口等组成
    3、局部变量表中存放着方法中定义的参数,该空间大小在程序运行开始的时候已经固定。
    4、操作数栈中存放着方法执行过程中产生的消费变量,通过压栈弹栈的方式进行运算。
    5、栈不需要垃圾回收,因为使用过的方法(栈帧)都会被弹出。
    ps:为什么过多的嵌套会报错,因为会生成太多的栈帧,使得有固定长度的栈超出容量,使动态增长的栈申请不到那么多的空间。
    如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError
    如果虚拟机栈可以动态扩展,扩展时无法申请做够的内存,将会爬出OutOfMemorryError
  • 本地方法栈
    与虚拟机栈发挥的作用非常类似,他们之间的区别是虚拟机栈为虚拟机执行java方法服务,而本地方法栈则为虚拟机使用到的native方法服务。与虚拟机栈一样,也会抛出StackOverflowErrorOutOfMemorryError异常。
  • 方法区
    具体来说,方法区其实是JVM规定的一种规范,是各个线程所共享的,它用来存储已被虚拟机加载的类信息、运行时常量池和静态常量池(字符串常量池已经被移动到堆中)、静态变量、即时编译后的代码等数据。具体的实现则在JDK1.8之前称为永久代,1.8之后被元空间替代。

  • 堆作为JVM中占地面积最大的一块区域,其中存放着所有实例化过后的对象。(这里等之后更新GC时候再增添老年代和新生代的分别

元空间与永久代的区别

元空间作为永久代的代替者,自然是有它的优势,首先,永久代是存储在JVM内存中的,而元空间是存储在系统内存中的,这就使得元空间的大小不是永久代可以比拟的。

  • 字符串常量池在永久代中,易出现性能问题和内存溢出。
  • 类和方法的大小难以确定,给永久代大小指定带来困难。
  • 永久代会给GC带来不必要的复杂性
  • 有的JVM虚拟机根本没有永久代这个区域,去除掉之后方便不同的虚拟机合并,比如hotspot和其他。

JVM内存模型面试必知

一、JVM如何调优
Java分为编译和执行两个过程,在我们执行.class文件的时候,会通过java xxxx.class或者java -jar xxx.jar来执行对应文件,此时,有三个参数需要我们记住,它们是-Xms -Xmx -Xss.
-Xms:设置JVM中堆的初始空间的大小
-Xmx:设置JVM中堆的最大空间大小
-Xss:设置JVM中堆栈的空间大小
这里可以使用 java -Xms 128M -Xmx 128M -Xss 128M来设置对应空间的大小,其中堆的初始值以及最大值最好设置成相同,否则在进行堆扩容的时候会发生内存抖动现象。
二、JVM中堆和栈的区别
1、内存分配策略
1.1、静态储存:静态储存要求在代码编译期就确定其大小,要求代码中不能包含可变长字符串以及实例对象等代码块,不允许有嵌套以及递归等。
1.2、栈式存储:栈式存储在编译器不要求确定其大小,而是要求在代码执行时(模块入口)确定其大小,结合我们之前说的栈的存储结构,每个栈帧都会为其分配固定的空间。
1.3、堆式存储:编译时以及运行时都无法确定其大小,全部进行动态分配,这里存放着可变长字符串对象以及对象。
2、堆和栈之间的联系
我们都知道,每个线程在执行某个代码块时,其中可能会有多个方法以及对象的引用,每执行一个方法都需要创建一个栈帧,此时运行过程中会用到对象的引用,为此,栈特意创建了一个引用变量,该变量指向引用对象在堆内的首地址,该变量在初次引用时创建,在运行到该引用的作用域之外时被释放。堆中的对象在没有引用指向时,不定期的被回收。
3、堆栈之间的区别
管理方式:栈自动释放空间,栈帧的弹栈以及引用变量的释放就是自动释放,堆进行垃圾回收。
空间大小:堆的空间远大于栈的空间。
碎片相关:栈产生的碎片远远小于堆,一方面是因为栈规定了具体的大小,二是因为栈的操作简单,只有弹栈和压栈,这种压栈弹栈的方式减少了内存碎片。
分配方式:栈有静态分配以及动态分配,而堆只有动态分配。
效率:栈的执行效率较高。
三、栈、堆、元空间之间的联系
元空间存放着一段程序执行所用到的全部的类信息,即所有方法及属性。
堆中存放着一段程序执行所用到的全部对象实例,其中包括方法及属性。
栈中存放着一段程序执行所用到的全部堆中对象实例的引用变量,该变量指向堆中对象的首地址。

GC垃圾回收机制

前言:今天看了Java中关于垃圾回收的一些视频还有其他大佬的一些博客,这里说一下我的看法。
Java的垃圾回收机制是Java中分很重要一块,因为有了垃圾回收,我们不用像在C以及C++中那样手动进行垃圾处理,但是我们要清楚,任何技术都不是万能的,Java的垃圾回收同样存在着一些问题,比如,创建对象太多时,JVM中的堆还没来得及清理就被撑爆了,所以关于垃圾回收的方面,我只需要掌握两方面的知识,即哪种对象会被垃圾回收垃圾回收的方式是什么

垃圾回收之什么垃圾会被回收

Java在进行垃圾回收之前,会对在堆中存放的所有对象进行扫描,决定要回收的对象,此时就产生了两种方法。

引用计数算法

该算法会对对象的引用数进行计算,对象刚创建时计数为0,每当对象被引用一次时,就会+1,反之当对象的引用被置为NULL或者执行到了对象引用范围之外时就会-1。当扫描到计数为0的对象,就会为其加上标志,意味着该对象可以被干掉了。
缺点:该方法无法解决对象间相互引用的问题,俗称我中有你,你中有我,无限套娃。

public class People{ 
public People p;
}

public void static main(String[] args){ 
People p1 = new People();
People p2 = new People();
p1.p = p2;
p2.p = p1;

System.cg();//
}

这段代码中,父类中有一个相同类型的变量,我们创建了两个该类对象,并将其应用分别赋给对方的变量,这样形成了循环的引用,这样不管如何,它们的计数永远都会大于0,这种方式明显是不行的。

可达性分析算法

可达性分析算法使用了离散数学中图论的原理,通过一个GC Root,来将所有和其有直接引用关系或者间接引用关系的对象全部串联在一起,像一个图一样,当某个对象不再被引用时,它就成为了一个孤岛,此时扫描到它时就会给其加上标记。
GC Root的选取范围
一、虚拟机栈中引用的对象(如存在栈帧中的本地变量表中,比如在某个方法中New了一个)。
二、方法区中的常量引用对象。
三、方法区中的类静态属性引用的对象(比如在一个类中声明了一个静态类)
四、本地方法区中引用的对象(该对象可能不是Java的对象,可能是C这样的对象)
五、活跃线程的引用对象。
在以上五个范围内,通过在根中不断引用其他对象,形成了一张图。这样就解决了上面循环引用的问题,毕竟它们只是小范围的在套娃,没有从根部出发。

垃圾回收之回收方式

回收方式是建立在不同区域上的,Java垃圾回收在两个部分进行(之前是三个部分,JDK8之后取消了永久代),他们是老年代以及年轻代。

标记——清除算法

该方法通过上面的可达性分析算法确定要回收的对象之后,将有标记的对象全部垃圾回收。
缺点:该方法在进行回收时会造成碎片化的问题。试想一下,一个连续分配的区域,不同的对象占据不同的大小,当其中一个被回收,空余的大小不能保证有个其他对象正好的放进去,或多或少会产生内存碎片。

复制算法

复制算法的原理是将整个区域分成2部分,其中一部分进行对象的存放,每当我们要清除无用对象的时候,先将该区域内不进行垃圾回收的对象复制到另一半的区域之上,此后将该区域全部清除,这种方法避免了碎片化的问题,简单粗暴。
缺点:可用的空间少了,并且如果在无用对象较少,有用对象较多的情况下,复制是非常耗时间的。

标记——整理算法

该算法是标记——清除算法的改进版,在进行标记清除之后,还要将这些对象进行重新排列,这样就避免了解决碎片化的问题,但是也会消耗时间(毕竟要整理,就好比吃饭之后还要刷碗)。

分代收集算法 MinorGC(新生代收集算法)以及FullGC(老年代收集算法)

我们的堆为了使用上述的方法去垃圾回收,将整个堆分为了新生代以及老年代,并在其上分别执行不同的算法回收。

新生代:几乎所有的对象都是在新生代中创建的,这里要尽可能快速的收集掉那些生命周期短的对象。这里分为三个区域,Eden区(垃圾回收的重点区域)和两个Survivor区域,在这里执行的是复制算法,即在Eden去进行垃圾回收,将还有用的对象复制到其中一个Survivor区并将这些刚被复制来的对象年龄+1,之后将Eden区全部清空,此后再进行垃圾回收时,将Eden中有用的对象和存有数据的Survivor区中的对象,复制到另一个Survivor区,再将这两个区域清空,依次类推。
当Survivor区中的对象

  • 超出规定年龄的。
  • 超过Survivor空间大小的。
  • 新生成的大对象。

会被放到老年代中。

老年代:老年代用于存放那些不经常进行回收的,或者是满上面那三种条件被赶出来的,这里执行标记清除算法以及标记整理算法。
常见调优参数
调整新生代中Eden区和Survivor区的大小-XX: SurvivorRotio,一般设置为8:1。
设置老年代以及新生代空间比例-XX:NewRatio 一般是2:1
设置新生代到老年代的年龄阈值-XX:MaxTenuringThreshold默认是15。

因为我这里总结的东西比较多,具体程度没有非常精细,所以像具体了解可以看看这位博主写的Java垃圾回收机制,其中何时会触发FullGC是比较重点的问题。

语言特性

说到Java的语言特性,这里就不得不说多线程以及Lambda表达式了,其中多线程更是重中之重,我们对多线程的认识要做到理论与实践结合,否则光背多线程的理论知识是没用的,很多结论是建立在实验基础上的,强行体现自己做过在面试中是很危险的事情。

进程与线程

  • 我们都知道,OS的发展历程是由最初的串行化到批处理系统,再到现在的并发处理系统,而对于单CPU单核处理器来说,进程的提出帮助其实现了并发的功能。
  • 所谓的并发,其实还是按照轮转执行的,只不过此时执行的不是一个完整的程序,而是进程,进程保存了当前程序的运行状态、资源,所有进程被OS分配一个个的时间片,通过OS的调度算法进行时间片轮转,由于时间片非常短,这就造成了程序并发执行的现象。
  • 然而就有人提出,难道进程不可再分了吗?于是就出现了多线程的理念,一个进程可以产生多个线程,进程是分配系统资源的最小单位,而线程成为了CPU调度的最小单位
  • 进程是抢占CPU的调度单位,每个进程都有独立的地址并且资源之间不共享。而线程属于某个进程,共享其资源以及地址空间。
  • 多进程的程序要比多线程程序健壮,由于线程共享一个资源空间,在某个线程突然挂掉之后,其进程也会挂掉。

Java中进程和线程的关系:

1、Java将系统调用都进行了封装,线程以及进程也不例外。
2、Java中每运行一个程序就会产生一个进程,进程包含至少一个线程。
3、每个进程对应一个JVM实例,线程间共享JVM堆和方法区,每个线程拥有自己的虚拟机栈、程序计数器以及本地方法栈。
4、Java采用单线程编程模型,程序会自动创建一个线程。
5、主线程可以创建子线程,单原则上主线程要晚于子线程执行完,因为要回收各种方法和变量。

PS:关于多线程的问题,大部分都是实验结合着理论,这部分不像JVM以及GC,这部分是考验我们对多线程的应用如何,是不是已经做过对应的实验,多线程的使用场景很多,大多数都是高级应用,比如网络编程通信,其中就涉及socket、I/O、多线程以及其他的一些基础,大家对这块要多多练习,切勿纸上谈兵,下面多线程这块代码会占很长的篇幅,其中,我会尽量结合自己的理解来使他更通俗易懂,帮助大家能在平时编程中用到。

多线程编程之Thread以及Runnable是什么关系

作为多线程应该知道的最基础的东西,Thread类实现了Runnable接口,并扩展了一些方法。以下是Thread的源码。

使用Thread以及Runnable分别实现多线程。

package com.interview.javabasic.thread;

public class MyThread extends Thread { 

    private String name;

    public MyThread(String name) { 
        this.name = name;
    }

    @Override
    public void run() { 
        System.out.println("hello I can start!!!");
        try { 
            sleep(3000);
        } catch (InterruptedException e) { 
            e.printStackTrace();
        }
        System.out.println("我是"+name);
    }


    public static void main(String[] args) { 
        MyThread myThread = new MyThread("Tom");
        myThread.start();
    }
}

package com.interview.javabasic.thread;

import static java.lang.Thread.sleep;

public class MyRunnable implements Runnable { 

    private String name;

    public MyRunnable(String name) { 
        this.name = name;
    }

    @Override
    public void run() { 
        System.out.println("hello I can start!!!");
        try { 
            sleep(3000);
        } catch (InterruptedException e) { 
            e.printStackTrace();
        }
        System.out.println("我是"+name);
    }

    public static void main(String[] args) { 
        MyRunnable myRunnable = new MyRunnable("Jack");
        Thread thread = new Thread(myRunnable);
        thread.start();
    }
}


区别:继承Thread类之后可以直接实例化并且调用其start方法创建一个线程。实现Runnable接口需要将其实现类传入Thread类中,调用Thread的start方法。
ps:由于Java单一继承原则,所以在实现多线程时最好还是使用Runnable接口方式。

如何为run方法传递参数

  • 使用构造方法传参(就是上图的方法)
  • 使用Get、Set进行传参
  • 使用回调函数传参

如何处理线程的返回值问题

一、让主线程不断的循环等待子线程执行结束,设置循环不断调用父线程的sleep,直到子线程结束,获取返回值。
二、对子线程使用join方法,阻塞主线程,直到子线程结束,获取返回值(这里以及上面的的返回值指的是在run方法中在控制台输出,并非真正的返回)。

package com.interview.javabasic.thread;

public class CycleWait implements Runnable{ 
    private String value;
    public void run() { 
        try { 
            Thread.currentThread().sleep(5000);
        } catch (InterruptedException e) { 
            e.printStackTrace();
        }
        value = "we have data now";
    }

    public static void main(String[] args) throws InterruptedException { 
        CycleWait cw = new CycleWait();
        Thread t = new Thread(cw);
        t.start();
        //使用sleep方法
        while (cw.value == null){ 
            Thread.currentThread().sleep(100);
        }
        //使用join方法
        t.join();
        System.out.println("value : " + cw.value);
    }
}

三、我们都清楚,有时候我们在处理一个方法时,需要得到其返回结果才能知道具体执行情况,但是通过多线程的run方法,我们无法得知这个线程到底执行的怎么样,执行完了也不知道,于是Callable接口应运而生。
Callable接口:我们可以看callable接口的源码,可知其中只有一个方法,call方法,该方法会返回一个泛型类型的值。
使用callable实现多线程

一、使用FutureTask(可以对实现了Callable接口的类进行管理,如查看线程是否结束,接收返回值)。

package com.interview.javabasic.thread;

import java.util.concurrent.*;

public class MyCallable implements Callable<String> { 


    private String name;

    public MyCallable(String name){ 
        this.name = name;
    }


    @Override
    public String call() throws Exception { 
        System.out.println("start!!!");
        Thread.currentThread().sleep(5000);
        //这里模拟线程执行任务,阻塞5秒
        return name;
    }


    public static void main(String[] args) throws ExecutionException, InterruptedException { 
        MyCallable callable = new MyCallable("Tom");
        FutureTask<String> task = new FutureTask<>(callable);
        new Thread(task).start();
        if (!task.isDone()){ 
            System.out.println("该线程还在执行中");
        }
        System.out.println("线程返回值为"+task.get());

    }
}

其中Future是一个判断callable实现类执行情况以及获取返回值的泛型接口,FutureTask实现了RunnableFuture接口。
FutureTask可以调用其中的get方法获取Callable中执行call后的返回值,在调用期间会阻塞其他线程等待其执行完成。isDone方法会判断该方法是否执行完成,完成则返回true。FutureTask在高并发环境下确保任务只执行一次。

具体使用就是

  1. 创建一个类实现Callable接口,重写call方法
  2. 创建FutureTask对象,将callable实现类交给其管理
  3. 使用new Thread将FutureTask对象创建一个线程
  4. 使用FutureTask的方法决定获取返回值或者执行/取消线程操作。

二、使用线程池创建

package com.interview.javabasic.thread;

import java.util.concurrent.*;

public class MyCallable implements Callable<String> { 


    private String name;

    public MyCallable(String name){ 
        this.name = name;
    }


    @Override
    public String call() throws Exception { 
        System.out.println("start!!!");
        Thread.currentThread().sleep(5000);
        //这里模拟线程执行任务,阻塞5秒
        return name;
    }



    public static void main(String[] args) throws ExecutionException, InterruptedException { 
        MyCallable callable = new MyCallable("Tom");
        ExecutorService executorServicePool = Executors.newCachedThreadPool();
        Future<String> future = executorServicePool.submit(callable);
        if (!future.isDone()){ 
            System.out.println("线程还在执行中");
        }
        System.out.println("返回值是 "+future.get());
        executorServicePool.shutdown();
    }
}


具体使用就是

  • 创建一个类实现Callable接口,重写call方法
  • 创建线程池ExecutorService
  • 将Callable实现类提交到线程池中,使用submit()方法,该方法会返回一个Future实例。
  • 使用Future的方法决定获取返回值或者执行/取消线程操作。

线程的六状态

通过查看Thread类的源码,我们可以在其中找到一个名为State的Enum,其中有6个状态,分别是NEW、RUNNABLE、BLOCKED、WAITING 、TIMED_WAITING、TERMINATED

  • NEW:新建,意为刚创建完毕,还没有执行,对应没有start的线程。
  • RUNNABLE:运行,表示当前线程正在运行中或者正在CPU的轮转队列中。
  • BLOCKED:阻塞状态,表示当前线程已经进入了阻塞状态,需要获取排他锁才能继续执行。
  • WAITING:无限等待,表示当前线程进入了无限等待状态,需要被显式唤醒。调用join和未设置时间的waitsleep
  • TIMED_WAITING:有限期等待,时间到了之后会自动唤醒。调用了设置时间的wait、sleep。
  • TERMINATED:结束,已终止线程的状态,mian结束或者run结束之后。

wait和sleep的区别

  • wait和sleep都可以让当前线程进入到等待状态中,这里的本质区别是,sleep会让当前线程让出CPU,但是如果该线程获得了一个排他锁则不会释放。wait则会让出CPU以及当前保持的排他锁。
  • wait只能在synchronized块或方法中执行。
  • sleep可以在任意方法中执行
  • 如果该线程在某个地方被wait了,之后该线程重新唤醒的时候还会再次从该地方向下执行。(我在这里掉坑了,想了很久才搞懂)

Sleep方法

package com.interview.javabasic.thread;

public class WaitAndSleep implements Runnable { 

    //这里必须使用静态对象,因为锁的判定就是该对象唯一地址不可变(类锁)
    static Object lock = new Object();

    @Override
    public void run() { 
        System.out.println("我是线程"+Thread.currentThread().getName());
        synchronized (lock){ 
            System.out.println("这里是刚开始的锁执行");
            try { 
                Thread.currentThread().sleep(1000);
            } catch (InterruptedException e) { 
                e.printStackTrace();
            }
            System.out.println("我的方法已经执行完毕,我是线程"+Thread.currentThread().getName());
        }

    }

    public static void main(String[] args) { 
        new Thread(new WaitAndSleep(),"A").start();
// try { 
// Thread.sleep(500);
// } catch (InterruptedException e) { 
// e.printStackTrace();
// }
        new Thread(new WaitAndSleep(),"B").start();
    }
}

这里的结果是A先执行后,sleep了一秒,放弃了CPU,此时B执行到锁那块时,因为A没有释放锁,所以等待A继续执行完接下来的代码释放锁之后,再去执行B的代码。

Wait方法

package com.interview.javabasic.thread;

public class WaitAndSleep implements Runnable { 

    //这里必须使用静态对象,因为锁的判定就是该对象唯一地址不可变
    static Object lock = new Object();

    @Override
    public void run() { 
        System.out.println("我是线程"+Thread.currentThread().getName()+" 准备进入锁");
        synchronized (lock){ 
            System.out.println("我是线程"+Thread.currentThread().getName()+" 已经进入锁");
            try { 

                lock.wait(1000);
            } catch (InterruptedException e) { 
                e.printStackTrace();
            }
            System.out.println("我的方法已经执行完毕,我是线程"+Thread.currentThread().getName());
        }

    }

    public static void main(String[] args) { 
        new Thread(new WaitAndSleep(),"A").start();
        try { 
            Thread.sleep(500);
        } catch (InterruptedException e) { 
            e.printStackTrace();
        }
        new Thread(new WaitAndSleep(),"B").start();
    }
}


这里明显可以看出,A先执行之后调用wait方法,放弃了锁,之后B才能进去执行(由于两个线程的代码一样,所以在B进入之后也会执行到wait,所以结果是A先执行完)。

notify和notifyAll的区别

首先来明确几个概念

  1. 锁池EntryList:三个线程A、B、C,当A获取了锁,B和C就会进入锁池准备等A释放后竞争锁。
  2. 等待池WaitSet:三个线程A、B、C,当A获取锁之后,B、C因为某种原因进入等待池WaitSet,A释放锁之后,B和C不会去竞争锁。
  3. notify和notifyAll会将等待池的线程放入锁池,wait会将锁池中的线程放入等待池(wait时间到了也会进入锁池)。
  4. notify只会从等待池中随机挑选一个进入锁池。
  5. notifyAll将等待池中所有的线程放入锁池。
package com.interview.javabasic.thread;

public class MyNotify{ 

    public static void main(String[] args) { 
         final Object lock = new Object();//定义一个锁

         new Thread(new Runnable() { 
             @Override
             public void run() { 
                 System.out.println("当前线程是 A");
                 synchronized (lock){ 
                     System.out.println("线程A获得锁");
                     try { 
                         lock.wait();//释放锁进入等待池
                     } catch (InterruptedException e) { 
                         e.printStackTrace();
                     }
                     System.out.println("线程A执行完成");
                 }
             }
         },"A").start();
        //为了使结果明显,这里让主线程休眠一会儿再去创建A
        try { 
            Thread.sleep(10);
        } catch (InterruptedException e) { 
            e.printStackTrace();
        }

        new Thread(new Runnable() { 
            @Override
            public void run() { 
                System.out.println("线程B启动");
                synchronized (lock){ 
                    System.out.println("线程B进入锁");
                    lock.notify();
                }
            }
        },"B").start();
    }
}


这里可以看出,线程A执行到wait后(无限期wait)此后在B执行完成后调用了notify把线程A给释放了,所以A才能执行。

yield的含义

概念:yield执行后,该线程会提出一个当前线程愿意让出CPU的暗示,但是具体执行不执行还得看线程调度器怎么办。

如何中断线程

interrupt:该方法执行后,会通知线程调度器说,这个线程该中断了,同时将线程中断标志置为true,此时中断不中断还得看线程调度器怎么办。
两种状态
1、在被阻塞的情况下,那么线程将立刻退出阻塞状态并且抛出一个InterruptException。
2、在运行状态下,会将该线程的中断标记置为true,设置完成之后该线程继续执行,不受影响。
一种解决方法
在正常运行任务时,线程会经常检查自己的中断位,如果被设置了中断标志,就会自行停止线程。
被抛弃的方法
stop是一个被抛弃的方法,该方法可以直接调用,中断其他线程(不管其他线程在干什么),这样的方式太过于不安全,所以被废弃了。

synchronized锁

如何保证线程安全,如何保证在并发访问下系统的安全性,这就需要互斥锁。互斥锁保证在同一时间只能有一个线程去访问临界资源,即互斥性。同时应该保证该线程对资源所做的操作应该是下一个访问临界资源的线程可以看到的,即可见性。
synchronized锁通过实现方式的不同可以分为类锁以及对象锁。
类锁:synchronized块中传入当前类对象,如synchronized(People.class){...},或者实现静态方法private static synchronized void do(){...}
对象锁:synchronized块中传入当前实例对象synchronized(this){...}或者非静态方法private synchronized void go(){...}
以下是实验对象锁以及类锁区别的方法

package com.interview.javabasic.thread;

public class MySynchronized implements Runnable{ 

    //共享方法
     private static synchronized void doStaticFunc(){ 
        System.out.println("当前线程" + Thread.currentThread().getName() + "进入共享方法");
        try { 
            Thread.sleep(1000);
        } catch (InterruptedException e) { 
            e.printStackTrace();
        }
        System.out.println("线程"+Thread.currentThread().getName()+"执行共享方法完毕");
    }

    //共享块
    private void doStatic(){ 
// System.out.println("目前进入执行共享块");
        synchronized (this){ 
            System.out.println("当前线程"+Thread.currentThread().getName()+"进入共享块");
            try { 
                Thread.sleep(1000);
            } catch (InterruptedException e) { 
                e.printStackTrace();
            }
            System.out.println("线程"+Thread.currentThread().getName()+"共享块执行完毕");
        }
    }

    @Override
    public void run() { 
        String threadName = Thread.currentThread().getName();
        if (threadName.startsWith("A")){ 
            doStaticFunc();
        }else if (threadName.startsWith("B")){ 
            doStatic();
        }else if (threadName.startsWith("C")){ 
            doStaticFunc();
            doStatic();
        }
    }

}
package com.interview.javabasic.thread;

public class SynchronizedDemo { 

    public static void main(String[] args) { 
        //这里测试共享方法,分别实例化两个不同的对象来测试类锁
        new Thread(new MySynchronized(), "A_test1").start();
        new Thread(new MySynchronized(), "A_test2").start();
        //结果说明即使是不同的对象进入,只要是类锁(方法)就会执行锁
        new Thread(new MySynchronized(),"B_test1").start();
        new Thread(new MySynchronized(),"B_test2").start();
        //结果说明,如果使用对象锁(块锁),不同的对象不会去竞争锁
        MySynchronized mySynchronized = new MySynchronized();
        new Thread(mySynchronized,"B_test1").start();
        new Thread(mySynchronized,"B_test2").start();
        //结果说明,使用块锁(对象锁)会在使用同一个对象时竞争锁
        new Thread(new MySynchronized(), "C_test1").start();
        new Thread(new MySynchronized(), "C_test2").start();
        //结果说明,使用不同的对象来同时执行块锁(对象锁)以及方法锁(类锁)互不阻碍,类锁会被阻塞,而对象锁不会阻塞
        MySynchronized mySynchronized = new MySynchronized();
        new Thread(mySynchronized,"C_test1").start();
        new Thread(mySynchronized,"C_test2").start();
        //这个结果说明对于同一个对象,类锁和对象锁互不干扰,类锁执行时会阻塞类锁,不会阻塞对象锁,对象锁执行时也会阻塞

    }
}

以上结果充分说明了对象锁以及类锁的区别
1、有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块。
2、如果锁住的是同一个对象,那么线程A访问对象的同步代码块时,另一个访问对象同步代码块的会阻塞。
3、如果锁住的是同一个对象,那么线程A访问对象的同步方法时,另一个访问对象同步方法的会阻塞。
4、如果锁住的是同一个对象,那么线程A访问对象的同步方法时,另一个访问对象同步块的会被阻塞,反之亦然,因为锁住了同一个对象,不管访问的是块还是方法,其他线程在没释放之前都被阻塞。
5、同一个类的不同对象的对象锁互不干扰。
6、类锁由于也是一个特殊的对象锁,因此表现和上述1、2、3、4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁是同步的。
7、类锁和对象锁互不干扰。

synchronized的底层实现原理

说到synchronized的底层实现,除非去看源码以及对底层非常了解,不然总会有些许的偏差,这里结合我看视频以及刷博客总结一下我的看法。
要了解其实现原理必须先了解对象的构造,对象由对象头、实例数据、对齐填充字节组成,其中我们重点看对象头
对象头由Mark Word以及ClassMatadata Address组成。
Mark Word:默认存储对象的hashCode、分代年龄、锁类型、锁标志位等信息。
ClassMatadata Address:存储指向对象的类元数据,JVM通过该指针确定该对象是哪个类的数据。
具体可以看看这个Java对象的组成
其中synchronized对应Mark Word中的锁类型为重量锁的锁。
看完了Mark Word我们再来看一个很重要的结构Monitor,该结构非常重要,可以这么说,每个对象生来就有一个Monitor,我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。 会随着对象的创建而创建,销毁而销毁。在Mark Word中,重量级锁标志位指向了该对象的Monitor,每当有一个线程访问某对象的同步块(方法)时,Monitor就会自动为该对象加锁。
Monitor继承ObjectMonitor类实现作用,该类的代码已经被嵌入到了JVM的实现源码中,我们可以去网上查看该类的源码,其中有几个的变量WaitSet、EntryList、count、woner,前两个就是我们之前提到的等待池以及锁池,当某个线程获取锁时,monitor会将owner字段置为该线程,并将count+1在当前线程在执行重入的时候count会继续自增,每当该线程执行完一个同步区域时count-1直到为0,当count不为0时,其他线程只能在EntryList中等待锁释放。
这样就可以解释为什么每个对象都可以成为锁。
对于同步代码块来说,看其字节码文件我们可以得知,其中有两个字段分别是monitorenter和monitorexit,分别指向同步代码的开始以及结束位置。
重入:重入意为在某线程执行该同步代码块时还可以再执行同步代码块,获得锁之后还可以再获得锁(没有执行完之后)。

synchronize(this){ 
	synchronized(this){ 
		//同步代码块中还可以执行同步代码块
	}
}

对于同步方法来说,看其字节码文件,会发现有一个ACC_SYNCHROZED字段,每当有线程去访问一个方法时,会先去查看该字段,如果有的话,则会加一个Monitor锁,否则不会。
不管是对于块还是方法,都会有一个异常处理方法(隐式),如果在块中发生了异常,那么在异常处理完毕时会调用monitorexit释放锁。在方法中发生了异常,在异常执行完毕抛出方法之外时,会释放掉该锁。

synchronize锁的优化

在早期,synchronized由于太过笨重,依赖于系统中的Mutex Lock,所以在线程切换时要从核心态切换到用户态,开销非常大。所以在JDK6之后,对该锁进行了优化(在JVM层面)。
一、自适应自旋锁。在大多数情况下,线程对于锁的占用只是一小段时间,此时如果该线程被阻塞挂起,就会进行线程调度而切换回核心态,之后不阻塞时再从核心态切换到用户态,就会加大系统开销。这时提出让该线程不放弃CPU,而是使用CPU执行忙循环来等待其他线程释放锁。对于该锁的自旋次数,JVM会进行动态调整,通过观察上一个线程自旋时间以及锁的拥有者,如果很快就获得了锁那么就加大自旋次数,反之则减小次数。
二、锁消除。在JIT编译时(java字节码编译为本地机器码的过程Execution Engine,并且因为这个过程是在程序运行时期完成的所以称之为即时编译)对运行的上下文进行扫描,去除不可能存在竞争的锁,比如单线程执行时,如果此时加锁,就会增加系统加锁的开销。如下因为SrtingBuffer是线程安全的,所以运行时要给加锁,但是对于单线程来说,根本没有加锁的必要,这时进行锁消除会去除申请锁的时间。

public void getBuffer(String value){ 
	StringBuffer b = new StringBuffer();
	b.append(value);
}

三、锁粗化。在某种情况下,出现了不断为某个对象加锁的现象,甚至出现了循环中加锁。此时会将锁的范围加大到整个方法的外部,只加一次锁。

public void getBuffer(String value){ 
	StringBuffer b = new StringBuffer();
	for(int i=0;i<100;i++){ 
	b.append(value);
	}
}

四、synchronized锁的四种状态(改变锁的状态来进行锁优化)
锁有四种状态分别为 无锁 偏向锁 轻量级锁 重量级锁。
其中无锁不用解释,重量级锁就是synchronized加锁阻塞其他线程的形式。
偏向锁:在大部分情况下,我们执行的都是单线程,只存在一个线程多次获取该锁。对于加了锁的对象来说,此时有一个线程A访问该锁,在Mark Word中会将锁类型置为偏向锁,在该线程再次访问该锁的情况下,这时只需对比Mark Word中锁标志位是否是偏向锁,以及对比该线程ID和Mark Word中的ThreadID是否相同,如果两个条件都满足,则无需再做同步操作,这样节省了大量锁申请的时间。如果此时有其他线程来访问该锁,不会马上膨胀到重量级锁。该状态只适用于单个线程执行的场景。
轻量级锁:轻量级锁适用于两个线程交替执行的场景,在另一个线程访问偏向锁时,偏向锁会马上膨胀到轻量级锁,此时两个线程交替执行。如果此时有线程同时访问该锁,则会马上膨胀到重量级锁。

锁的内存语义

当线程释放锁时,Java内存模型(JMM)会将其对应的本地内存的共享变量更新到主内存中。
当线程获取锁时,将线程对应本地内存中的共享变量置为无效,会从主内存中Copy一份共享变量数据,将其拷贝在本地内存中。从而使得被监视器保护的临界区代码必需从主内存中获得共享变量。

synchronized和ReentrantLock的区别

先来了解ReentrantLock,ReentrantLock是一个类实现了Lock接口(JDK5之后出现),Lock接口中的方法是基于AQS来编写的,AQS即是AbstractQueuedSynchronizer,一个用来构建锁和同步工具的框架。
由于该锁是一个对象,则拥有比synchronized的粒度更细的控制,可以控制公平性,实现公平锁。
必须显式的调用lock()进行加锁,以及使用unlock()释放锁。
性能未必比synchronized的性能好,并且也是可以重入的(在锁的内部继续调用锁)。

实现公平锁

public class People implements Runnable{ 
	private static ReentrantLock lock = new ReentrantLock(true);
	//设置为true的时候会设置公平锁.
	@Overried
	Public void run(){ 
		lock.lock();
		System.out.println("hello world" + Thread.currnetThread.getName());
		lock.unlock();
	}
	public static void main(String[] args){ 
		new Thread(new People(),"A").start();
		new Thread(new People(),"B").start();
	}
}
  • 通过ReentrantLock lock = new ReentrantLock(true);来设置公平锁。
  • 公平锁解决了一定的线程饥饿问题,但是由于Java调度很少出现线程饥饿问题,所以在使用公平锁的时候除非是一定要使线程公平执行,否则请使用不公平锁,因为公平锁会增加系统开销。
  • 公平锁指的是获取锁的顺序按照线程先后调用lock方法的顺序执行。
  • 非公平锁是抢占全部看运气,synchronized就是非公平锁。

ReentrantLock将锁对象化

  • 判断是否有线程,或者某个特定线程在排队等待获取锁。(那些线程在争抢锁)
  • 感知有没有成功获取锁。
  • 带超时的获取线程的尝试。(超时取消获取锁)

将wait/notify/notifyAll进行对象化
既然锁都能对象化,那么对对锁的操作也可以实例化成对象,Condition就是这么一个类,在其内部封装了对wait/notify/notifyAll的操作,该类中的子类也继承了AQS类(说明使用时也需要进行同步操作),使用时由ReentrantLock的实例对象创建,且对于同一个锁进行操作时,必须由同一个ReentrantLock创建,并且在lock()和unlocak()之间使用。
Condition类的用法

synchronized和ReentrantLock的区别

  • synchronized是关键字,而ReentrantLock是类。
  • ReentrantLock可以对锁的等待时间设置,避免死锁。
  • ReentrantLock可以获取各个锁的信息。
  • ReentrantLock可以灵活的实现多路通知。
  • synchronized实现机制是Mark Word,而Lock调用unsafe的park()方法。

JMM的内存可见性

JMM内存模型

Java内存模型(Java Memory Model)简称JMM,本身是一种抽象的概念,并不真实存在,它描述的是一组实现规则,通过这组规则定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)访问方式。
JVM规定,每当有一个线程创建时,JVM就会为其分配对应的工作内存,每个线程只能去修改自己工作内存中的变量,其外所有线程共享一个对象的主内存,主内存中存放着对象的所有变量,工作内存中存放着主内存中变量的拷贝。
工作内存:

  • 存储当前方法的所有本地变量信息,本地变量对其他线程不可见(其实存放的是主内存中变量的拷贝)。
  • 字节码行号指示器,Native方法信息
  • 属于数据私有区域,不存在线程安全问题

主内存

  • 存放着Java实例对象
  • 包括成员变量、类信息、常量、静态变量等。
  • 主内存属于数据共享的区域,多线程并发访问会引起线程安全问题。

JMM与Java内存区域划分是不同的概念层次

  • 每当有线程操作共享变量时,会使用主内存中共享变量的值替换工作内存中的变量。
  • 当线程完成操作时,会将本地内存中的值刷新到主内存。

JMM和Java内存区域划分之间的关系

  • JMM描述的是一组规则,通过这组规则控制着共享数据区域以及私有数据区域变量的访问方式,围绕原子性、有序性、可见性展开。
  • 相似点:存在共享数据区和私有区域

主内存与工作内存的数据存储类型以及操作方式归纳

  • 方法里的基本数据类型本地变量(局部变量)直接存储在工作内存的栈帧结构中。
  • 引用类型的本地变量(局部变量):引用类型存放在工作内存中,实例存储在主内存中。
  • 实例对象的成员变量、静态变量、类信息均会被存放在主内存中(只有局部变量可能存放在工作内存中)。
  • 主内存共享的方式是线程各自拷贝一份数据到工作内存中,操作完成之后刷新回主内存。

JMM如何解决可见性问题

有这么一个问题,线程A在本地内存中修改了数据之后还没有刷新回到主内存,线程B此时如何得知线程A的修改并作出正确的操作。这就要求不同的指令间有着某种顺序关系。在程序执行的过程中,JVM为了执行的效率,会打乱指令的执行顺序,也就是进行指令重排序,但是不能随意重排序,需要满足两个条件。
一、在单线程环境下不能改变程序运行的结果。
二、若存在数据依赖的关系,则不允许重排序。
即无法通过happen-before原则推导出来的,才能进行指令的重排序。
JMM通过实现内存屏障,来阻止某些指令的重排序来保证数据的可见性,即通过happens-before原则来实现数据的可见性。
happens-before:A操作的结果需要对B可见,则A与B存在happens-before关系。该原则是解决多线程下数据共享时线程安全问题的保障。

happens-before八大原则

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
  2. 锁定规则:一个unlock操作先行发生于后面对于同一个锁的lock操作。
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,那么操作A先行发生于操作C。
  5. 线程启动原则:Thread对象的start()方法先行发生于此线程的每一个动作。
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
  8. 对象终止规则:一个对象的初始化完成先行发生于他的finalize()方法开始。

happens-before的概念
如果两个操作不满足上述任何happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。
如果操作A happens-before 于操作B,那么在操作A在内存上所做的操作对操作B都是可见的。

volatile:JVM提供的轻量级同步机制

  • 保证被volatile修饰过的共享变量对所有线程总是可见的。
  • 禁止指令的重排序优化。
  • 对volatile的操作应该是原子性的,否则很难保证在执行过程中不出现线程安全性问题。
  • volatile变量为何立即可见?当写一个volatile变量时,JMM会立刻把该线程对应的共享变量值刷新到主内存中。在读一个volatile变量时,JMM会把该线程对应的工作内存置为无效。
    volatile容易出现问题的地方
public class People implements Runnable(){ 
	private static volatile int value;
	@Override
	public void run(){ 
		value++
	}

}

乍一看此代码没有什么问题,在不同线程访问该同步区域时,value会执行自增的操作,并且value是对所有线程可见的。但是实际执行却不是这样,value++不是真正的原子性操作,对字节码文件进行反编译可以看到,当前线程是先获取到了value的值,之后再进行自增,此时如果有其他的线程来访问此value(因为没有加锁)两个线程同时自增,这样就导致本来的+2成为了+1,根本原因就是因为对volatile进行的操作不是原子性的。

  • volatile如何禁止重排优化?
    内存屏障(MemoryBarrier)
    1、保证特定操作的执行顺序,通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化。
    2、保证某些变量的内存可见性,强制刷新出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
    3、单例的双重检测实现。
public class Singleton{ 
private static  volatile Singleton instance;
	private Singleton(){ }
	public static volatile Singleton getInstance(){ 
		//第一次检测
		if(instance==null){ 
			//同步
			synchronized(Singleton.class){ 
				if(instance==null){ 
					//多线程环境下可能出现问题的地方
					instance = new Signleton();
				}
			}
		}
		return instence;
	}		
}

在上面的代码中,多个线程创建单例模式的对象,如果不对该对象加volatile关键字时,会有线程安全问题。在对象创建过程中有三个步骤加载、链接、初始化。其中链接中又分为三个步骤(上面讲过,检查、分配内存和初始化、引用)其中分配完内存之后,初始化分两步,其一将地址空间赋予对象(此时instence!=null),其二初始化变量,这两步没有依赖关系,可以进行指令的重排序。如果先执行地址分配,此时该对象还没有初始化完毕,但是已经被创建并且返回了,这就造成了线程不安全。解决方法,将对象上加上volatile关键字,告诉其不能在该对象的实例化上进行重排序。

volatile与synchronized的区别

  1. volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主内存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞直到该线程完成变量操作为止。
  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别。
  3. volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized可以保证修改变量的原子性和可见性。
  4. volatile不会造成线程阻塞;synchronized可能会造成线程阻塞
  5. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

CAS

CAS(Compare and Swap)是一种乐观锁,它假定不会出现并发冲突的现象,只在提交时检查是否违反了数据完整性。
CAS是一种高效实现线程安全性的方法。

  • 支持原子更新操作,适用于计数器,序列发生器等场景。
  • 属于乐观锁机制,号称lock-free。
  • CAS操作失败时由开发者决定继续尝试(类似自旋锁)或者执行别的操作。
  • 包含三个操作数——内存位置V、预期原值A、新值B

当一个线程要修改某个共享数据时,先从主内存中获取到该值,并将其赋予预期原值A,在修改完原值A之后,将改变的新值赋予新值B,此后将预期原值同内存位置V对比,如果相同,则把新值写回主内存,如果不相等,那么就重复上述操作直到成功为止。
CAS多数情况下对开发者是透明的
我们在使用CAS时,并不直接去使用它,而是去使用实现了CAS的一些并发包。
J.U.C的atomic包提供了常用的原子性数据类型以及引用、数组等相关子类型和更新操作工具,是很多线程安全程序的首选。
Unsafe虽然能提供CAS提供服务,但因能够操纵任意内存地址读写而有隐患。
Java9之后,可以使用Variable Handle API来代替Unsafe
缺点:

  • 循环时间长,则开销时间大
  • 只能保证一个共享变量的原子操作
  • ABA问题(如果线程A、B在操作共享变量X,此时A获得了共享变量X,并将其修改成为Y,之后再次改成了X(由于没有加锁,此时都可以访问),然而在B看来,共享变量没有发生改变,此刻可以将新值写回,但是已经发生了改变。之后为了解决该漏洞,Java通过AtomicStampedReference为内存位置加上了版本号,当版本号一致时才可以进行写回。

例如著名的value++问题,value++在用volatile修饰时,因为其不是原子性操作,所以并发访问时会造成线程不安全。而CAS解决了这个问题,如线程AB都拿到了共享变量(相当于Value++的第一步查看初始值)之后线程A执行,此时他发现预期原值和内存位置的值相同,所以写回新值,但是B操作时,突然发现内存位置的值和预期原值不同,所以更新失败。

Java线程池

在Java中,为了解决线程复用的问题,提出了Java线程池。原因是在WEB程序中,服务器每接收到一个请求就会生成一个线程处理,之后销毁,但是由于请求数量多且时间比较短,Java就会频繁的创建销毁线程,因而大大降低系统处理的效率,在创建销毁线程的消耗上要大于正常处理业务时消耗的资源。
使用Executors创建不同配置的线程池来满足不同场景下的应用。

创建不同的线程池

  1. newFixedThreadPool(int nThreads) 指定工作数量的线程池
    如果存入的线程个数超出了指定数量,就会将其放在线程队列中;有线程退出则会创建新的线程补足数量。
  2. newCachedThreadPool()处理大量短时间工作任务的线程池
    试图缓存线程并且重用,如果没有缓存线程,则创建新的工作线程;
    如果线程闲置的时间超过阈值,则会被终止并且移出缓存;
    系统长时间闲置的时候,不会消耗什么资源
  3. newSingleThreadExecutor()
    创建唯一的工作线程来执行任务,如果线程异常结束,还会创建另一个取代它,保证顺序执行。
  4. newSingleThreadScheduledExecutor()与newScheduleThreadPool(int corePoolSize)
    定时或者周期性的工作调度,两者的区别在于单一工作线程还是多个线程,前者单一后者多个。
  5. newWordStealingPool()(JDK8引入)
    内部会构建ForkJoinPool,利用working-stealing算法,并行的处理任务,不保证处理顺序。

以上方法会通过传入不同的参数来实现不一样的ThreadPoolExecutor

Fork/Join框架(JAVA7提供,用于并行执行任务的框架)

  • 把大任务分割成若干个小任务并行执行,最终汇总每个小任务结果后得到大任务结果的框架.
  • 是ExecutorService的一种具体实现,为了更好的使用多处理器。为了那些能递归能拆解成小任务执行的而设计的。
  • 使用working-stealing算法:某个线程从其他队列中窃取任务来执行,Fork/Join会创建任务队列来分配给每个线程,该队列是双端队列,窃取任务的线程从队列尾部拿任务执行。

为什么要使用线程池

  • 降低资源消耗,利用可重复使用的线程,降低线程创建和销毁的消耗。
  • 提高线程的可管理能力。

J.U.C的三个Executor接口

  • Executor接口:(起源)运行新任务的简单接口,将任务提交和任务执行细节解耦。
    只有一个方法,不同的线程池实现了不同的execute方法。
  • ExecutorService接口:具备管理执行器和任务生命周期的方法,提交任务机制更完善(如返回Future而不是void的run方法)。继承于Executor但是扩展了Executor的方法如submit
  • ScheduledExecutorService:扩展了ExecutorService,支持Future和定期执行任务。

线程池的执行流程:


线程池在接受到用户传递的任务时,将其放入WorkQueue工作队列中,之后调用线程集合(其中线程集合会管理线程的创建和销毁)中的线程去处理任务队列中的任务,其中,线程池中的每个线程都抽象为静态内部类Worker,ThreadPool线程池维护的额其实就是一组Worker。
查看其源码,我们可以知道

该类继承自AQS这个并发基础框架,其中firstTask保存着传入的任务,而Worker在被初始化之后则会调用getThreadFactory()来创建一个线程,去执行传入的任务。

Worker(Runnable firstTask) { 
            setState(-1); // inhibit interrupts until runWorker
            this.firstTask = firstTask;
            this.thread = getThreadFactory().newThread(this);
       }

TreadPoolExecutor:实现了不同线程池

上面的五类线程池创建方法都是在传入不同参数的情况下调用了ThreadPoolExecutor类的重载构造方法来创建不同的线程池。其中的参数有。

  • corePoolSize:核心线程数量,对于不同的线程池,这个值也不同。
  • maximumPoolSize:线程不够用的时候能创建的最大线程数。
  • workQueue:任务等待队列,如果当前任务数量大于线程数量时,会将Work封装在一个workQueue队列中,使用不同队列有不同的排队机制。
  • keepAliveTime:抢占的顺序不一定,看运气。线程池维护线程所允许的空余时间,当线程池中的数量大于corePoolSize时没有任务提交,核心线程外的任务不会立即销毁,而是会等待,直到时间超过keepAliveTime。
  • threadFactory:创建新线程,Executors.defaultThreadFactory()。会使新创建的线程有相同的优先级,并且是非守护线程,同时也设置了线程名称。
  • headler:线程池的饱和策略
    如果阻塞队列满了并且没有空闲线程,如果继续提交任务,就需要一些策略来处理该任务。
    1、AbortPolicy:直接抛出异常,这是默认策略
    2、CallerRunsPolicy:用调用者所在的线程来执行任务。
    3、DiscardOldestPolicy:丢弃队列中最靠前的任务,并且执行当前任务。
    4、DiscardPolicy:直接丢弃任务。
    5、实现RejectedExecutionHandler接口的自定义handler。

新任务提交execute执行后的判断

  • 如果运行的线程少于corePoolSize,则创建新的线程来处理任务,即使线程池中的其他线程是空闲的。
  • 如果线程池中的线程数量大于等于corePoolSize且小于maximumPoolSize,则只有当workQueue满时才创建新的线程去处理任务;
  • 如果设置的corePoolSize和maximumPoolSize相同,则创建的线程池大小是固定的,这时如果有新任务提交的话,则workQueue未满,则将请求放入workQueue中,等待有空闲的线程去从workQueue中去任务并处理。
  • 如果运行的线程数量大于等于maximumPoolSize,这时如果workQueue已经满了,则通过handler所制定的策略来处理任务。

关于各个参数的含义,这里有更细致的讲解,可以参考这篇博客

此外有线程池的执行流程

以及线程池大小如何选定,这里关于大小问题

AtomicInteger :线程池中掌握线程状态以及当前线程数的字段。

AtomicInteger是ThreadPoolExecutor中的一个字段,用来实现标题所说的。

线程池的状态

  • RUNNING:能接受新提交的任务,并且也能处理阻塞队列中的任务。
  • SHUTDOWN:不再接受新提交的任务,但是可以处理存量任务。在线程池处于RUNNING状态下,对线程池调用RUNNING方法则会进入该状态。
  • STOP:不能接收新提交的任务,也不处理存量任务,并且线程会中断,调用SHUTDOWNNOE()会进入该状态。
  • TIDYING:所有任务已经终止,正在进行最后的打扫工作
  • TERMINATED:terminated()方法执行后进入该状态,最为一个标识。

    工作线程的生命周期

线程池的大小如何选定

  • CUP密集型:线程数=按照核数或者核数+1
  • I/O密集型:线程数=CPU核数+(1+平均等待时间/平均工作时间)

Java异常处理

Java异常是Java中比较重要的一块,异常可以为我们提供错误提示,可以通过异常去找到对应的错误以及发生的位置,甚至还可以在发生异常后在catch块中处理。
异常处理解决了三个问题

  • 什么异常会被抛出
  • 异常在什么位置被抛出
  • 为什么会抛出异常

Java异常体系


Java异常体系全部继承于Throwable类,下分两个大块,分别是Error以及Exception。
Error:系统发生的错误,一般不可控制,程序无法处理,编译器不做检查,无法预防,属于JVM需要承担的责任。
RuntimeException:运行时异常,Java在编译时不会提示用try-catch笼罩,但是在运行时可能会发生异常,是不可知的,一般由程序员负责,常见的有NullPointException(空指针异常)、IndexOutOfBoundsException(数组越界异常)。
其他的Exception:checkedException,当我们在使用文件时,编译器会自动提示要我们去加一个try-catch,这属于检查型的异常,是可预知的,从编译器检查的异常,属于Java编译器应当负担的责任。

向上抛出和try-catch的区别

在我们使用异常处理时,总会出现两种方式,第一种是显式的调用try-catch-finally,在出现问题时,catch会捕获其中的异常,并通过在其中书写的对应措施去处理。还有一种方式,便是使用throws在方法后边向上抛出一个异常,该异常如果不用try-catch处理,则会一层一层的向上抛出,直到到了顶级的调用(例如main)还没有处理,那么程序会终止。

常见的Exception和Error

RuntimeException

  • NullPointException:俗称空指针异常,当调用空对象、调用空对象的方法等会抛出。
  • ClassCastException:类型强制转换异常,当我们将两个不同的类(没有父子关系)强制转化时会抛出该异常。
  • ILLgealArgumentException:传递非法参数异常,当我们的参数类型以及参数个数都皮配不上的话就会抛出该异常。
  • IndexOutOfBoundsException:数组下边越界异常。
  • NumberFormatException:数字格式异常,使用非数字格式字符串强制转化数字时会抛出。

非RuntimeException

  • ClassNotFountException:找不到指定的Class
  • IOException:其下包括FileException等都是可以编译时就能检查到的。
  • SQLException:使用JDBC编写时可能会抛出SQL异常

Error

  • NoClassDefFoundError:找不到class定义的异常。
    1、类依赖的class或jar不存在
    2、类文件存在,但是存在于不同的域,多次加载相同的class
    3、大小写问题,Javac编译时是无视大小写的,很可能编译出来的class与想要的class不一样。
  • StackOverflowError:栈已经被耗尽,常见于静态分配的栈空间被耗尽,可能是使用了不断递归的方法。
  • OutOfMemoryError:内存溢出(栈无法申请到足够的内存)

Exception处理机制

1、抛出异常
当异常发生时,此时会创建一个异常对象,交由运行时系统处理问题,此异常对象包含发生的异常类型和出现问题的地方。
2、捕获异常
自下而上去寻找合适的异常处理器(ExceptionHander)处理异常,否则终止运行。

处理异常应遵守的准则

  • 具体明确:应尽可能的使用精准的异常区捕获,避免使用Exception将所有的异常全部捕获,抛出的一场应该能通过类名和message说明问题和原因。
  • 提早抛出:应尽可能的发现并且抛出异常,便于精准定位。
  • 延迟捕获,异常的捕获和处理应当尽可能演出,让掌握更多信息的作用域来处理。

Java类库

说到Java的类库,我们在这里只提几个比较重要的类库。

Java的Collection体系

我们在学习C语言的时候,常常会自己去书写数据结构以及相应的算法,而在Java中,我们似乎没有见到这些东西,是因为Java已经帮我们将其封装成了对应的工具类。

上面这个图是不完全的,但是已经画出了我们常用的集合类。
其中我们重点关注的有List下的ArrayList、Vector、LinkedList。和Set下的TreeSet、HashSet。

TreeSet的两种排序方法

  • 通过元素之间的比较性进行排序
    此时需要在参数类中实现Comparable接口,重写compareTo方法,之后将该重写过后的类add到treeSet中。
  • 通过比较器来实现排序
    此时需要创建一个自定义比较器,实现Comparator<‘T>的接口,实现其中的compare方法,将两个类进行比较,之后将比较器传入到Set中实现。
    TreeSet的两种排序方法对比

集合之Map

Map保存具有映射关系的值,Map由Key-Value组成,其中key由Set实现,所以是不可重复的。
map整体架构图

HashMap、hashTable、ConcurrentHashMap

HashMap

HashMap:(Java8之前)数组+链表

HashMap(Java8及以后):数组+链表+红黑树

HashMap中的元素个数如果超过初始值X加载因子(初始值为16),就会自动扩容。HashMap在第一次使用时才会初始化。当每个节点中的元素超过 TREEIFY_THRESHOLD时,链表就会转化为红黑树,当元素个数少于UNTREEIFY_THRESHOLD时,树又会转化成链表。
HashMap中put方法的逻辑

  1. 如果HashMap未被初始化过,则初始化
  2. 对Key求Hash值,然后再计算下标
  3. 如果没有碰撞,直接放入桶中
  4. 如果碰撞了,以链表的方式链接在后面
  5. 如果链表长度超过阈值,就把链表转成红黑树
  6. 如果链表长度低于6,就把红黑树转回链表
  7. 如果节点已经存在就替换旧值
  8. 如果桶满了(容量16*加载因子0.75),就需要resize(扩容两倍后重排)

关于HashMap中如何有效减少碰撞

  • 扰动函数:促使元素位置均匀分配,减少碰撞几率
  • 使用final对象,并采用合适的equals()和hashCode()方法

关于HashMap的详细解答,可以看看这位大神博主的为什么面试要问hashmap 的原理,真的是太详细了。

HashTable

  • 早期Java类库提供的哈希表的实现
  • 线程安全:涉及到修改HashTable的修改操作方法,使用synchronized修饰。
  • 串行化的方式运行,性能较差

ConcurrentHashMap

如何优化hashTable?虽然HashMap处理数据的能力非常高效,但是他不是线程安全的,而hashTable是线程安全的,但是由于其效率非常低下,串行化执行,在高并发环境下性能不是太高。所以在JDK5之后,ConcurrentHashMap应运而生。

  • 通过锁细粒度化,将整个锁拆解成多个锁进行优化。早期的ConcurrentHashMap:通过分段锁Segment来实现。

  • 当前的ConcurrentHashMap:CAS+synchronized使锁更加的细致。synchronized只锁当前bucket(数组位置),只要没有并发冲突的话就没有线程安全问题。

  • ConCurrentHashMap出自J.U.C包

  • key值不允许传入null值

  • 对数据的更新是通过CAS方式进行操作

ConcurrentHashMap:put方法

  1. 判断Node[]数组是否初始化,没有则进行初始化操作
  2. 通过hash定位数组的索引坐标,是否有Node节点,如果没有则使用CAS进行添加(链表的头结点),添加失败则进入下次循环。
  3. 检查到内部正在扩容,就帮助其一起扩容。
  4. 如果f!=null(头节点不为空),则使用synchronized锁住f元素(链表/红黑二叉树的头元素) 如果是Node(链表结构)则执行链表的添加操作。如果是TreeNode(树形结构)则执行树添加操作。
  5. 判断链表长度已经达到临界值8,当然这个8是默认值,大家也可以做调整,当节点超过这个值就需要把链表转化为树结构。

ConcurrentHashMap总结:比起Segment,锁拆的更细

  • 如果头结点没有锁,则尝试使用CAS插入头结点,失败则循环重试。
  • 若头结点已经存在,则尝试获取头结点的同步锁,再进行操作。

HashMap线程不安全,数组+链表+红黑树
HashTable线程安全,是通过锁住整个对象,实现分段锁,数组+链表
ConcurrentHashMap线程安全,CAS+同步锁,数组+链表+红黑树
HashMap的key和value均可以为null,而其他的两个类不支持

J.U.C (java.util.concurrent:提供了并发编程的解决方案)

  • CAS是Java.util.concurrent.atomic包的基础
  • AQS是Java.util.concurrent.Locks包以及一些常用类比如Semophore,ReentrantLock等类的基础

J.U.C包的分类

  • 线程执行器executor(线程池的创建)
  • 锁Locks(synchronized、ReentrantLock)
  • 原子变量类atomic(CAS,非阻塞)
  • 并发工具类tools(下面介绍)
  • 并发集合collections(List、Set、Map)

并发工具类tools

  • 闭锁CountDownLatch
    让主线程等待一组事件发生后继续执行。事件指的是CountDownLatch里的countDown()方法。子线程在执行完conutDown方法后还会继续执行,只不过是告诉主线程说我不会拖你后腿。

  • 栅栏CyclicBarrier
    会等待其他线程,且能阻塞自己当前线程,所有线程必须同时到达栅栏位置后,才能继续执行。
    所有线程到达栅栏处,可以触发另外一个预先设置的线程。

  • 信号量Semaphore
    控制某个资源可同时访问的线程个数

  • 交换器Exchanger
    两个线程到达同步点后,相互交换数据(只能用于两个线程交换)

collections并发集合

BlockingQueue:提供可阻塞的入队和出队操作。

主要用于生产者-消费者模式,在多线程场景时生产者线程在队列尾部添加元素,而消费者线程则在队列头部消费元素,通过这种方式能够达到将任务的生产和消费进行隔离的目的。

  • ArrayBlockingQueue:一个由数组结构构成的有界阻塞队列(先进先出);
  • LinkedBlockingQueue:一个由链表结构组成的有界/无界阻塞队列(先进先出);
  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列;
  • DealyQueue:一个使用优先级队列实现的无界阻塞队列;
  • SynchronizedQueue:一个不储存元素的阻塞队列;
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列;
  • LinkedBlockingDeque:一个由链表组成的双向阻塞队列;

Java的IO体系

在Java中,IO是我们平时使用频率比较大的一块,IO又分为同步阻塞IO、同步非阻塞IO、异步非阻塞IO。

首先我们需要了解,什么是同步阻塞、同步非阻塞、异步非阻塞。
阻塞和非阻塞:这里用比较形象的烧水例子来说明,在小的时候,妈妈让我们守在水壶跟前,等到水开之后关火,我们因为小所以被阻塞在了水壶跟前,这就是同步阻塞。当我们长大了之后,同样是烧水,此时我们只会抽空去看一看水的情况,大部分时间都可以干自己的事情,这种抽空去看水壶的情况叫做同步非阻塞,但是还需要我们不断的去看水壶情况,这就很麻烦。于是,我们更换了水壶,换成了带声音的,水开了之后会通知我们,这样,我们只需要插电,然后就可以干自己的事情,之后水壶给我们一个反馈,说我开了,我们再去处理。

BIO:同步阻塞IO

BIO也就是我们传统意义上的IO操作,比如字节流、字符流、net包中的Socket、ServerSocket、HttpUrlSocket,这些操作都会阻塞当前线程,直到返回结果。
没有实践就没有发言权,接下来举个例子,客户端与服务器进行通信。

package com.interview.javabasic.IO;

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class BIO { 

    public void server(int port) throws IOException{ 
        //将serverSocket绑定到指定的端口中
        final ServerSocket server = new ServerSocket(port);
        ExecutorService executor = Executors.newFixedThreadPool(6);

        while (true){ 
            //阻塞到新的客户端连接
            final Socket clientSocket = server.accept();
            System.out.println("Accepted connection form" + clientSocket);

            //创建一个子线程去处理客户端的请求
            executor.execute(()->{ 
                try { 
                    BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
                    while(true){ 
                        //等待客户端socket的不为空输入流
                        String str = reader.readLine();
                        if (str == null) { 
                            break;
                        }
                        System.out.println("客户端说:" + str);
                    }
                    

                    reader.close();
                    server.close();
                    clientSocket.close();
                } catch (IOException e) { 
                    e.printStackTrace();
                }
            });
        }
    }

}

当我们在使用BIO进行Socket通信时,经常会接收到一个客户端请求,就建立一个线程去处理,完成之后该线程又被销毁,所以为了减少创建销毁线程的系统开销,则使用线程池创建线程。
总结:BIO会阻塞线程,并等到数据处理完成之后才会解除阻塞。

NIO

简介:NIO作为Java1.4之后提出对IO进行优化的操作,做到了同步非阻塞,具体实现为线程会不定期的去询问是否IO操作已经完成,类似于自旋锁,底层实现是使用了IO多路复用技术,通过文件描述符以及select、poll、epoll等方式,查看每个IO的状态,如果可以操作就对其进行操作。
关于select、poll、epoll的区别

关于select、poll、epoll所能打开的最大连接数

关于FD增加后带来的IO效率问题

以上三张图我们可以看出,NIO是使用了系统调用的文件监视器来实现非阻塞IO,其中epoll的效率最高,他可以根据当前文件的活跃程度来监视文件,其中poll、select都是通过线性遍历的方式来查看文件状况的。

NIO的执行流程

NIO的核心类
Channel
Buffer
Selector
关于这三大类,这里就不再赘述了,详情可以参考NIO三大核心类,这篇文章可比我写的太好了,希望大家在面试时不要只会原理,不会实现。
这里只大概提一下,channel是管道可以理解为IO中的流,通过Buffer来向这个“流”中读取或者写入数据,此外channel可以注册到selector中,这样就实现了一个线程管理多个管道进行IO。

NIO2 (AIO)

AIO在NIO的基础上实现了异步非阻塞,由Java7之后实现,该机制使用了回调方法,使得我们不必再去轮询是否完成IO操作,而是在发出IO请求之后就不管了,直到IO操作完成会给当前线程返回一个完成的回调函数,真正实现了异步操作。

  • 基于回调:实现CompletionHandler接口,调用时触发回调函数
  • 返回Future:通过isDone()查看是否已经准备好,通过get()等待返回数据

BIO、NIO、AIO的区别


其中IO时最简单的,方便易懂,而NIO和AIO是比较难,此外IO只能单线程执行,而NIO只需要一个子线程处理IO,到了AIO则不需要线程,因为实现了异步操作。

Spring篇将会写一篇独立篇章,毕竟堆在一起大家也不想看。

完结感言:从开始的JVM到现在的工具类,总算是把Java大致过了一遍,相对于JVM来说,后面的Collection、HashMap、线程、IO这些都需要大量的实际操作,如果没有实践过,理论再纯熟也会露馅,如果读到这里的读者是大三第一季度以及以下的,那么劝您最好按照上面的流程走一遍,并且您需要有自己的项目(Spring框架+其他可选技术)、对算法、数据结构、数据库、网络、缓存(Redis)、OS、Linux都有一定的了解。
Java的知识浩如烟海,短短的几个月根本不可能掌握上述的全部,学习是一个迭代的过程,苦闷的学习无非是想让自己在接下来的生活中过得更好而已。
如果时间能够重来,我一定会提早规划,而不是匆忙上阵。

2021.8.22更新,大家可以去看看我的SpringCloud的学习总结,对于初学微服务的同学应该会有帮助。

springcloud微服务入门使用

    原文作者:MrSuper_cat
    原文地址: https://blog.csdn.net/MrSuper_cat/article/details/107886703
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系管理员进行删除。