您现在的位置是:首页 >技术杂谈 >5-《Kotlin》网站首页技术杂谈

5-《Kotlin》

_yao_ 2024-06-17 11:25:17
简介5-《Kotlin》

5-《Kotlin》

1.kotlin基础: From Java To Kotlin

常量与变量

//java
String name = "niubi";	final String name = "niubi";
//kotlin
var name = "niubi"		val name = "niubi"

null 声明

//java
String name= null;
//kotlin
var name:String?=null

空判断

//Java
if (text != null) {
 	int length = text.length();
}
//Kotlin
text?.let {
 val length = text.length
}// or simply val length = text?.length

字符串拼接

//Java
String firstName = "firstName ";
String lastName = "lastName ";
String message = "My name is: "+ firstName + " " + lastName;
//kotlin
val firstName = "firstName "
val lastName = "lastName "
val message = "My name is: $firstName $lastName"

换行

//Java
String text = "First Line
" +
             "Second Line
" +
             "Third Line";
//Kotlin
val text = """
        |First Line
        |Second Line
        |Third Line
        """.trimMargin()

三元表达式

//Java
String text = x > 5 ? "x > 5" : "x <= 5";
//Kotlin
val text = if (x > 5)
              "x > 5"
           else "x <= 5"

操作符

//java
final int andResult  = a & b;
final int orResult   = a | b;
final int xorResult  = a ^ b;
final int rightShift = a >> 2;
final int leftShift  = a << 2;
//Kotlin
val andResult  = a and b
val orResult   = a or b
val xorResult  = a xor b
val rightShift = a shr 2
val leftShift  = a shl 2

。。。。等等

2.Kotlin 的延迟初始化: lateinit var 和 by lazy

private var name0: String //报错
private var name1: String = "xiaoming"//不报错 
private var name2: String? = null //不报错

可是有的时候,我并不想声明一个类型可空的对象,而且我也没办法在对象一声明的时候就为它初始化,那么这时就需要用到 Kotlin 提供的延迟初始化。Kotlin 中有两种延迟初始化的方式:
一种是 lateinit var,一种是 by lazy。

1.lateinit var

lateinit var 只能用来修饰类属性,不能用来修饰局部变量,并且只能用来修饰对象,不能用来修饰基本类型(因为基本类型的属性在类加载后的准备阶段都会被初始化为默认值)。

lateinit var 的作用:让编译期在检查时不要因为属性变量未被初始化而报错。

2.by lazy

by lazy的作用:真正做到了声明的同时也指定了延迟初始化时的行为,在属性被第一次被使用的时候能自动初始化。
by lazy 本身是一种属性委托。属性委托的关键字是 by。by lazy 的写法如下:

//用于属性延迟初始化

val name: Int by lazy { 1 }

//用于局部变量延迟初始化

 public fun foo() {
	 val bar:String by lazy { "hello" }
	 println(bar)
}

by lazy 要求属性声明为 val,即不可变变量,在 java 中相当于被 final 修饰。这意味着该变量一旦初始化后就不允许再被修改值了(基本类型是值不能被修改,对象类型是引用不能被修改)。{}内的操作就是返回唯一一次初始化的结果。
by lazy 可以使用于类属性或者局部变量。
实现原理:(了解附加属性)
当一个属性 name 需要 by lazy 时,具体是怎么实现的:

  • 1.生成一个该属性的附加属性:name$$delegate;
  • 2.在构造器中,将使用 lazy(()->T)创建的 Lazy 实例对象赋值给 name$$delegate;
  • 3.当该属性被调用,即其 getter 方法被调用时返回 name$delegate.getVaule(),而
    name$delegate.getVaule()方法的返回结果是对象name$delegate内部的_value属性
    值,在 getVaule()第一次被调用时会将_value 进行初始化,往后都是直接将_value 的
    值返回,从而实现属性值的唯一一次初始化。

3 Kotlin Tips:怎么用 Kotlin 去提高生产力(kotlin优势)

Tip1- 更简洁的字符串

kotlin除了有单个双引号的字符串,还对字符串加强,引入了三个引号,“”"中可以包含换行、反斜杠等等特殊字符;同时,Kotlin中引入了字符串模版,方便字符串的拼接,可以用$符号拼接变量和表达式。注意,在kotlin中,美元符号$是特殊字符,在字符串中不能直接显示,必须经过转义,方法1是用反斜杠,方法二是${‘$’}

Tip2- Kotlin中大多数控制结构都是表达式

首先,需要弄清楚一个概念语句和表达式,然后会介绍控制结构表达式的优点:简洁

语句和表达式是什么?

表达式有值,并且能作为另一个表达式的一部分使用

语句总是包围着它的代码块中的顶层元素,并且没有自己的值

Kotlin与Java的区别

Java中,所有的控制结构都是语句,也就是控制结构都没有值

Kotlin中,除了循环(fordodo/while)以外,大多数控制结构都是表达式(if/when等)

Example1:if语句

java中,if 是语句,没有值,必须显式的return

public int max(int a, int b) {
    if (a > b) {
        return a;
    } else {
        return b;
    }
}

kotlin中,if 是表达式,不是语句,因为表达式有值,可以作为值return出去,类似于java中的三目运算符a > b ? a : b

fun max(a: Int, b: Int): Int {
    return if (a > b) a else b
}

上面的if中的分支最后一行语句就是该分支的值,会作为函数的返回值。这其实跟java中的三元运算符类似,

public int max2(int a, int b) {
    return a > b ? a : b;
}

上面是java中的三元运算符,kotlin中if是表达式有值,完全可以替代,故kotlin中已没有三元运算符了,用if来替代。

上面的max函数还可以简化成下面的形式

fun max2(a: Int, b: Int) = if (a > b) a else b

Example2:when语句

Kotlin中的when非常强大,完全可以取代Java中的switch和if/else,同时,when也是表达式,when的每个分支的最后一行为当前分支的值

// java中的switch
	public String getPoint(char grade) {
	    switch (grade) {
	        case 'A':
	            return "GOOD";
	        default:
	            return "UN_KNOW";
	    }
	}

java中的switch有太多限制,我们再看看Kotlin怎样去简化的

fun getPoint(grade: Char) = when (grade) {
    'A' -> "GOOD"
    else -> "UN_KNOW"
}

同样的,when语句还可以取代java中的if/else if

Tip3- 更好调用的函数:显式参数名(命名参数)/默认参数值

Kotlin的函数更加好调用,主要是表现在两个方面:

  • 1,显式的标示参数名,可以方便代码阅读;
  • 2,函数可以有默认参数值,可以大大减少Java中的函数重载。

@JvmOverloads

在java与kotlin的混合项目中,会发现用kotlin实现的带默认参数的函数,在java中去调用的化就不能利用这个特性了。这时候可以在kotlin的函数前添加注解@JvmOverloads,添加注解后翻译为class的时候kotlin会帮你去生成多个函数实现函数重载。

Tip4-扩展函数和属性

在这里插入图片描述
以项目中StringExt.kt中部分代码示例:

//扩展属性:获取String最后一个字符
val String.lastChar: Char
    get() = get(length - 1)

//扩展函数:字符串保留2位小数
fun String.keep2Decimal(): String {
    val format = DecimalFormat()
    format.maximumFractionDigits = 2
    format.isGroupingUsed = false  //不对数字串进行分组
    return format.format(this.toDouble())
}
var name:String="123.321"
//只需import完了就跟使用自己的属性一样方便了。
//使用扩展属性
val lastChar = name.lastChar

//使用扩展函数
val keep2Decimal = name.keep2Decimal()

Kotlin为什么能实现扩展函数和属性这样的特性?

在Kotlin中要理解一些语法,只要认识到Kotlin语言最后需要编译为class字节码,Java也是编译为class执行,也就是可以大致理解为Kotlin需要转成Java一样的语法结构,Kotlin就是一种强大的语法糖而已,Java不具备的功能Kotlin也不能越界的。

上面的扩展函数转成Java后的代码

/*
* 扩展函数会转化为一个静态的函数,同时这个静态函数的第一个参数就是该类的实例对象
* */
/*
* 获取的扩展属性会转化为一个静态的get函数,同时这个静态函数的第一个参数就是该类的实例对象
* */
public static final char getLastChar(@NotNull StringBuilder $receiver) {
    Intrinsics.checkParameterIsNotNull($receiver, "$receiver");
    return $receiver.charAt($receiver.length() - 1);
}

对于扩展函数,转化为Java的时候其实就是一个静态的函数
对于扩展属性也类似,获取的扩展属性会转化为一个静态的get函数

可以看到扩展函数和扩展属性适用的地方和缺陷,有两点:

扩展函数和扩展属性内只能访问到类的公有方法和属性,私有的和protected是访问不了的

扩展函数不能被override,因为Java中它是静态的函数

Tip5- 懒初始化by lazy 和 延迟初始化lateinit

上面已有详细介绍。

Tip6- 不用再手写findViewById

利用kotlin-android-extensions插件,activity中import对应的布局即可。插件会自动根据布局的id生成对应的View成员。直接拿id用即可。如: tip6Tv.text = “XXXX”

原理是什么?插件帮我们做了什么?

在编译阶段,插件会帮我们生成视图缓存,视图由一个Hashmap结构的_$_findViewCache变量缓存,会根据对应的id先从缓存里查找,缓存没命中再去真正调用findViewById查找出来,再存在HashMap中。另外在onDestroyView会清掉缓存

Fragment需要注意,不能在onCreateView方法里用view,不然会出现空指针异常,需要在onViewCreate里,原理是插件用了getView来findViewById.故在onViewCreated中getView还是空的,原理就好理解了。

Tip7- 利用局部函数抽取重复代码

Kotlin中提供了函数的嵌套,在函数内部还可以定义新的函数。这样我们可以在函数中嵌套这些提前的函数,来抽取重复代码。

Java写法
 private void checkIsBlank(){
     if (TextUtils.isEmpty(textviewOne.getText())){
         throw new IllegalArgumentException("");
     }
     if (TextUtils.isEmpty(textviewTwo.getText())){
         throw new IllegalArgumentException("");
     }
     if (TextUtils.isEmpty(editText.getText())){
         throw new IllegalArgumentException("");
     }
 }

Java优化后代码
 private void checkIsBlank(){
     checkTextView(textviewOne);
     checkTextView(textviewTwo);
     checkTextView(editText);
 }
 private void checkTextView(TextView view){
     if (TextUtils.isEmpty(view.getText())){
         throw new IllegalArgumentException("");
     }
 }

Kotlin写法
fun checkIsBlank(){
    fun checkTextView(view: TextView){
        if (view.text.isNullOrBlank())
            throw IllegalArgumentException("")
    }
    checkTextView(textviewOne)
    checkTextView(textviewTwo)
    checkTextView(editText)
}

Kotlin的写法和Java优化后代码相比,代码量并没有减少,那为什么我们推荐使用局部函数,而不推荐把重复代码提取成一个独立的函数呢?那是因为,在当前代码文件中,我们只有checkIsBlank一个函数使用到了这段重复的代码,别的函数并没有任何相关逻辑代码,所以使用局部函数的话,不仅让重复代码的用途和用处更明确了,函数相关性也大大提高了

Tip8- 使用数据类来快速实现model类

//Kotlin会为类的参数自动实现get set方法
class User(val name: String, val age: Int, val gender: Int, var address: String)

//用data关键词来声明一个数据类,除了会自动实现get set,还会自动生成equals hashcode toString
data class User2(val name: String, val age: Int, val gender: Int, var address: String)

Tip9- 用类委托来快速实现装饰器模式

通过继承的实现容易导致脆弱性,例如如果需要修改其他类的一些行为,这时候Java中的一种策略是采用装饰器模式:创建一个新类,实现与原始类一样的接口并将原来的类的实例作为一个成员变量。
与原始类拥有相同行为的方法不用修改,只需要直接转发给原始类的实例。如下所示:

* 常见的装饰器模式,为了修改部分的函数,却需要实现所有的接口函数
* */
class CountingSet<T>(val innerSet: MutableCollection<T> = HashSet<T>()) : MutableCollection<T> {

    var objectAdded = 0
    
    override val size: Int
        get() = innerSet.size

    /*
    * 需要修改的方法
    * */
    override fun add(element: T): Boolean {
        objectAdded++
        return innerSet.add(element)
    }

    /*
    * 需要修改的方法
    * */
    override fun addAll(elements: Collection<T>): Boolean {
        objectAdded += elements.size
        return innerSet.addAll(elements)
    }

    override fun contains(element: T): Boolean {
        return innerSet.contains(element)
    }

    override fun containsAll(elements: Collection<T>): Boolean {
        return innerSet.containsAll(elements)
    }

    override fun isEmpty(): Boolean {
        return innerSet.isEmpty()
    }

    override fun clear() {
        innerSet.clear()
    }

    override fun iterator(): MutableIterator<T> {
        return innerSet.iterator()
    }

    override fun remove(element: T): Boolean {
        return innerSet.remove(element)
    }

    override fun removeAll(elements: Collection<T>): Boolean {
        return innerSet.removeAll(elements)
    }

    override fun retainAll(elements: Collection<T>): Boolean {
        return innerSet.retainAll(elements)
    }
}`

如上所示,想要修改HashSet的某些行为函数add和addAll,需要实现MutableCollection接口的所有方法,将这些方法转发给innerSet去具体的实现。虽然只需要修改其中的两个方法,其他代码都是模版代码。
只要是重复的模版代码,Kotlin这种全新的语法糖就会想办法将它放在编译阶段再去生成。
这时候可以用到类委托by关键字,如下所示:

/*
* 通过by关键字将接口的实现委托给innerSet成员变量,需要修改的函数再去override就可以了
* */
class CountingSet2<T>(val innerSet: MutableCollection<T> = HashSet<T>()) : MutableCollection<T> by innerSet {

    var objectAdded = 0

    override fun add(element: T): Boolean {
        objectAdded++
        return innerSet.add(element)
    }

    override fun addAll(elements: Collection<T>): Boolean {
        objectAdded += elements.size
        return innerSet.addAll(elements)
    }
}

通过by关键字将接口的实现委托给innerSet成员变量,需要修改的函数再去override就可以了,通过类委托将10行代码就可以实现上面接近100行的功能,简洁明了,去掉了模版代码。

Tip10- Lambda表达式简化OnClickListener

Tip11- kotlin常见内联扩展函数来简化代码

内联扩展函数之let

let扩展函数的实际上是一个作用域函数
场景一: 针对一个可null的对象统一做判空处理。
场景二: 明确一个变量所处特定的作用域范围内。

内联函数之with

适用于调用同一个类的多个方法时,可以省去类名重复,直接调用类的方法即可。经常用于Android中RecyclerView中onBinderViewHolder中,数据model的属性映射到UI上
使用前:
在这里插入图片描述
使用后:
在这里插入图片描述
内联扩展函数之run
适用于let,with函数任何场景。因为run函数是let,with两个函数结合体
,准确来说它弥补了let函数在函数体内必须使用it参数替代对象,在run函数中可以像with函数一样可以省略,直接访问实例的公有属性和方法,另一方面它弥补了with函数传入对象判空问题,在run函数中可以像let函数一样做判空处理
借助上个例子,
使用前:
在这里插入图片描述
使用后:
在这里插入图片描述

内联扩展函数之apply

整体作用功能和run函数很像,唯一不同点就是它返回的值是对象本身,而run函数是一个闭包形式返回,返回的是最后一行的值。正是基于这一点差异它的适用场景稍微与run函数有点不一样。
apply一般用于一个对象实例初始化的时候,需要对对象中的属性进行赋值。或者动态inflate出一个XML的View的时候需要给View绑定数据也会用到,这种情景非常常见。特别是在我们开发中会有一些数据model向View model转化实例化的过程中需要用到。

 menuAdapter = HomeMenuAdapter().apply {
            bindToRecyclerView(recyclerView_menu)
            setOnItemClickListener { _, _, position ->
                when (position) {
                    3 -> ActivityManager.start(ActivateActivity::class.java)
                }
            }
        }

内联扩展函数之also

also函数的结构实际上和let很像唯一的区别就是返回值的不一样,let是以闭包的形式返回,返回函数体内最后一行的值,如果最后一行为空就返回一个Unit类型的默认值。而also函数返回的则是传入对象的本身
适用于let函数的任何场景,also函数和let很像,只是唯一的不同点就是let函数最后的返回值是最后一行的返回值而also函数的返回值是返回当前的这个对象。一般可用于多个扩展函数链式调用

总结:(extension指是否为扩展函数)
在这里插入图片描述

Tip11- 高阶函数简化代码

**高阶函数:**以另一个函数作为参数或者返回值的函数

4 Kotlin数组和集合

1 kotlin数组

kotlin为数组增加了一个Array类,为元素是基本类型的数组增加了xxArray类(其中xx也就是Byte,Short, Int等基本类型)

Kotlin创建数组大致有如下两种方式:

  • 1.使用arrayOf(), arrayOfNulls(),emptyArray()工具函数。
 	var model1=CommonModel()
    var model2=CommonModel()
    var model3=CommonModel()
    //创建包含指定元素的数组,相当于java数组的静态初始化
    var a= arrayOf(model1,model2,model3)
    var b= intArrayOf(1,2,3)
    //创建指定长度为3,元素为null的数组,相当于java数组动态初始化
    var c= arrayOfNulls<Int>(3)
  • 2.使用Array(size: Int, init:(Int) -> T)
    第一个参数就是数组的大小。第二个参数是一个函数, init:(Int) -> T 代表这这个方法返回的类型是T只能有一个参数类型是Int型。Int就是该array的所对应的索引。
	private fun arrInit(): (Int) -> Int = { it * 2 }
    var array1 = Array<Int>(5, arrInit())
    var array2 = Array<Int>(5, {it})

2 kotlin集合

kotlin集合类同样有两个接口派生:CollectionMap。但Kotlin的结合被分成两个大类,可变集合和不可变集合。只有可变集合才可以添加修改,删除等处理操作。不可变集合只能读取元素。

kotlin只提供了HashSet,HashMap, LinkedHashSet, LinkedHashMap, ArrayList这5个集合实现类,而且他们都是可变集合,那么说好的不可变集合呢。kotlin的不可变集合类并没有暴露出来,我们只能通过函数来创建不可变集合。

list集合

创建一个不可变的list

val mList = listOf<Int>(1, 2, 3)

创建一个可变的list

val mList = mutableListOf<Int>(1, 2, 3)

emptyList()——创建一个空集合
listOfNotNull ()—— 创建的集合中不能插入null值
Map
创建一个不可变的Map

val mList = mapOf(Pair("key1", 1), Pair("key2", 2))

或者

//推荐
val mList = mapOf("key1" to 1, "key2" to 2)

创建一个可变的Map

val mList = mutableMapOf("key1" to 1, "key2" to 2)

此外还有

emptyMap()——创建一个空map
hashMapOf()——创建一个hashMap
linkedMapOf()——创建一个linkedMap
sortedMapOf()——创建一个sortedMap

5 Kotlin集合操作符

Kotlin中关于集合的操作符有六类:

总数操作符
过滤操作符
映射操作符
顺序操作符
生产操作符
元素操作符
  • 1.总数操作符
    any —— 判断集合中 是否有满足条件 的元素;
    all —— 判断集合中的元素 是否都满足条件;
    none —— 判断集合中是否 都不满足条件,是则返回true;
    count —— 查询集合中 满足条件 的 元素个数;
    reduce —— 从 第一项到最后一项进行累计 ;
    reduceRight —— 从 最后一下到第一项进行累计;
    fold —— 与reduce类似,不过有初始值,而不是从0开始累计;
    foldRight —— 和reduceRight类似,有初始值,不是从0开始累计;
    forEach —— 循环遍历元素,元素是it,可对每个元素进行相关操作;
    forEachIndexed —— 循环遍历元素,同时得到元素index(下标);
    max —— 查询最大的元素,如果没有则返回null;
    maxBy —— 获取方法处理后返回结果最大值对应的那个元素的初始值,如果没有则返回null;
    min —— 查询最小的元素,如果没有则返回null;
    minBy —— 获取方法处理后返回结果最小值对应那个元素的初始值,如果没有则返回null;
    sumBy —— 获取 方法处理后返回结果值 的 总和;
    dropWhile —— 返回从第一项起,去掉满足条件的元素,直到不满足条件的一项为止

  • 2.过滤操作符
    过滤后会返回一个处理后的列表结果,但不会改变原列表!!!
    filter —— 过滤 掉所有 满足条件 的元素
    filterNot —— 过滤所有不满足条件的元素
    filterNotNull —— 过滤NULL
    take —— 返回从第一个开始的n个元素
    takeLast —— 返回从最后一个开始的n个元素
    takeWhile —— 返回不满足条件的下标前面的所有元素的集合
    drop —— 返回 去掉前N个元素后 的列表
    dropLastWhile —— 返回从最后一项起,去掉满足条件的元素,直到不满足条件的一项为止
    slice —— 过滤掉 非指定下标 的元素,即保留下标对应的元素过滤list中
    指定下标的元素(比如这里只保留下标为1,3,4的元素)

  • 3.映射操作符
    map —— 将集合中的元素通过某个 方法转换 后的结果存到一个集合中;
    mapIndexed —— 除了得到 转换后的结果 ,还可以拿到Index(下标);
    mapNotNull —— 执行方法 转换前过滤掉 为 NULL 的元素
    flatMap —— 合并两个集合,可以在合并的时候做些小动作;
    groupBy —— 将集合中的元素按照某个条件分组,返回Map;

  • 4.顺序操作符
    reversed —— 相反顺序
    sorted —— 自然排序(升序)
    sortedBy —— 根据方法处理结果进行自然(升序)排序
    sortedDescending —— 降序排序
    sortedByDescending —— 根据方法处理结果进行降序排序

  • 5.生产操作符
    zip —— 两个集合按照下标组合成一个个的Pair塞到集合中返回
    partition —— 根据判断条件是否成立,拆分成两个 Pair
    plus —— 合并两个List,可以用"+"替代
    unzip —— 将包含多个Pair的List 转换成 含List的Pair

  • 6.元素操作符
    contains —— 判断集合中是否有指定元素,有返回true
    elementAt —— 查找下标对应的元素,如果下标越界会抛IndexOutOfBoundsException
    elementAtOrElse —— 查找下标对应元素,如果越界会根据方法返回默认值(最大下标经方法后的值)
    elementAtOrNull —— 查找下标对应元素,越界会返回Null
    first —— 返回符合条件的第一个元素,没有 抛NoSuchElementException
    firstOrNull —— 返回符合条件的第一个元素,没有 返回null
    indexOf —— 返回指定下标的元素,没有 返回-1
    indexOfFirst —— 返回第一个符合条件的元素下标,没有 返回-1
    indexOfLast —— 返回最后一个符合条件的元素下标,没有 返回-1
    last —— 返回符合条件的最后一个元素,没有 抛NoSuchElementException
    lastIndexOf —— 返回符合条件的最后一个元素,没有 返回-1
    lastOrNull —— 返回符合条件的最后一个元素,没有 返回null
    single —— 返回符合条件的单个元素,如有没有符合或超过一个,抛异常
    singleOrNull —— 返回符合条件的单个元素,如有没有符合或超过一个,返回null

6 说一下Kotlin的伴生对象(关键字companion)

在Java中可以通过static关键字声明静态的属性或方法。但是在Kotlin中并没有延续这个关键字,而是使用伴生对象实现,在class内部声明一个companion object代码块,其内部的成员变量和方法都将被编译为静态的。

class TestStatic {
    //伴生对象
    companion object Factory {
        val str: String = ""
        fun create(): TestStatic {
            println(this)
            return TestStatic()
        }
    }
}

Factory为最终生成的静态内部类类名,通常来说Factory名字可以省略,如果省略,类名为默认的Companion。

Kotlin中的也可写静态代码块,只需在companion object中嵌套一个init代码块。

companion object {
    //静态代码块
    init {
        val a = "adc"
    }
}

注意事项

一个类中最多只能有一个companion object代码块。
伴生对象本质上就是一个静态内部类,所以它还能继承其他类。

7.Kotlin 顶层函数和属性

创建一个文件写需要的属性或方法。在其它地方直接,import 包名.函数名来导入我们将要使用的函数,然后就可以直接使用了。Kotlin中通过使用顶层函数和顶层属性帮助我们消除了Java中常见的静态工具类,使我们的代码更加整洁,值得一试。

8 协程(Coroutines=cooperation+routines)

1 协程是什么

Kotlin 官方文档说「本质上,协程是轻量级的线程」。

进程,线程,协程的抽象概念

  • 1.进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。进程是一种抽象的概念,从来没有统一的标准定义。
  • 2.线程是操作系统能够进行调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。线程是在进程下,所以同一进程下的多个线程是能共享资源的。
  • 3.协程是单线程下实现多任务,它通过 yield 关键字来实现,能有效地减少多线程之间切换的开销。它是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。

协程基本使用

// 方法一,使用 runBlocking 顶层函数
// 通常适用于单元测试的场景,而业务开发中不会用到这种方法
runBlocking {
	getImage(imageId)
}
// 方法二,使用 GlobalScope 单例对象
//和使用 runBlocking 的区别在于不会阻塞线程。但在 Android 开发中同样不推荐这种用法,因为它的生命周期会和app一致,且不能取消
GlobalScope.launch {
	getImage(imageId)
}
// 方法三,自行通过 CoroutineContext 创建一个 CoroutineScope 对象
//推荐的使用方法,我们可以通过 context 参数去管理和控制协程的生命周期(这里的context和Android里的不是一个东西,是一个更通用的概念,会有一个Android平台的封装来配合使用)。
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
	getImage(imageId)
}

协程最常用的功能是并发,而并发的典型场景就是多线程。可以使用 Dispatchers.IO 参数把任务切到 IO 线程执行:

coroutineScope.launch(Dispatchers.IO) {
 ...
}

也可以使用 Dispatchers.Main 参数切换到主线程:

coroutineScope.launch(Dispatchers.Main) {
 ...
}

异步请求的例子完整写出来是这样的:

coroutineScope.launch(Dispatchers.Main) { // 在主线程开启协程
	val user = api.getUser() // IO 线程执行网络请求
	nameTv.text = user.name // 主线程更新 UI
}

而通常用java来写,是少不了回调方法的。

协程的「1 到 0」

多层网络请求:

coroutineScope.launch(Dispatchers.Main) { // 开始协程:主线程
	val token = api.getToken() // 网络请求:IO 线程
	val user = api.getUser(token) // 网络请求:IO 线程
	nameTv.text = user.name // 更新 UI:主线程
}

如果遇到的场景是多个网络请求需要等待所有请求结束之后再对 UI 进行更新。
比如以下两个请求:

api.getAvatar(user, callback)
api.getCompanyLogo(user, callback)

如果使用回调式的写法,本来能够并行处理的请求被强制通过串行的方式去实现,可能会导致等待时间长了一倍,也就是性能差了一倍:

api.getAvatar(user) { avatar ->
		api.getCompanyLogo(user) { logo ->
		show(merge(avatar, logo))
 	}
}

而如果使用协程,可以直接把两个并行请求写成上下两行,最后再把结果进行合
并即可:

coroutineScope.launch(Dispatchers.Main) {
	val avatar = async { api.getAvatar(user) } // 获取用户头像
	val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
	val merged = suspendingMerge(avatar, logo) // 合并结果
	show(merged) // 更新 UI
}

可以看到,即便是比较复杂的并行网络请求,也能够通过协程写出结构清晰的代码。需要注意的是 suspendingMerge 并不是协程 API 中提供的方法,而是我们自定义的一个可「挂起」的结果合并方法。至于挂起具体是什么,可以看后面。

让复杂的并发代码,写起来变得简单且清晰,是协程的优势。
这里,两个没有相关性的后台任务,因为用了协程,被安排得明明白白,互相之间配合得很好,也就是我们之前说的「协作式任务」。本来需要回调,现在直接没有回调了,这种从 1 到 0 的设计思想真的妙哉。

2 suspend

suspend 是 Kotlin 协程最核心的关键字。代码执行到 suspend 函数的时候会『挂起』,并且这个『挂起』是非阻塞式的,它不会阻塞你当前的线程。
创建协程的函数:

• launch
• runBlocking
• async

runBlocking 通常适用于单元测试的场景,而业务开发中不会用到这个函数,因为它是线程阻塞的。

接下来我们主要来对比 launch 与 async 这两个函数。

• 相同点:它们都可以用来启动一个协程,返回的都是 Coroutine,我们这里不需要纠结具体是返回哪个类。
• 不同点:async 返回的 Coroutine 多实现了 Deferred 接口。

Deferred的意思就是延迟,也就是结果稍后才能拿到。
我们调用 Deferred.await() 就可以得到结果了。

看看 async 是如何使用的:

coroutineScope.launch(Dispatchers.Main) {
	val avatar: Deferred = async { api.getAvatar(user) } // 获取用户头像
	val logo: Deferred = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
	show(avatar.await(), logo.await()) // 更新 UI
 }

可以看到 avatar 和 logo 的类型可以声明为 Deferred ,通过 await 获取结果并且更新到 UI 上显示。

await 函数签名:

public suspend fun await(): T

前面有个关键字是—— suspend

3 「挂起」的本质

协程中「挂起」的对象到底是什么?挂起线程,还是挂起函数?
都不对,我们挂起的对象是协程
协程可以使用 launch 或者 async 函数,协程其实就是这两个函数中闭包的代码块。launch ,async 或者其他函数创建的协程,在执行到某一个 suspend 函数的时候,这个协程会被「suspend」,也就是被挂起。
那此时又是从哪里挂起?从当前线程挂起。换句话说,就是这个协程从正在执行它的线程上脱离。

注意,不是这个协程停下来了!是脱离,当前线程不再管这个协程要去做什么了。

suspend 是有暂停的意思,但我们在协程中应该理解为:当线程执行到协程的suspend 函数的时候,暂时不继续执行协程代码了。

互相脱离的线程和协程接下来将会发生什么事情:

举例:获取一个图片,然后显示出来:

// 主线程中
GlobalScope.launch(Dispatchers.Main) {
	val image = suspendingGetImage(imageId) // 获取图片
	avatarIv.setImageBitmap(image) // 显示出来
} 

suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) {
 ...

这段执行在主线程的协程,它实质上会往你的主线程 post 一个 Runnable,这个 Runnable 就是你的协程代码:

handler.post {
	val image = suspendingGetImage(imageId)
	avatarIv.setImageBitmap(image)
}

线程:
如果它是一个后台线程:
• 要么无事可做,被系统回收
• 要么继续执行别的后台任务
跟 Java 线程池里的线程在工作结束之后是完全一样的:回收或者再利用。
如果它是 Android 的主线程,那它接下来就会继续回去工作:也就是
一秒钟 60 次的界面刷新任务。

协程:
线程的代码在到达 suspend 函数的时候被掐断,接下来协程会从这个 suspend 函数开始继续往下执行,不过是在指定的线程。
谁指定的?是 suspend 函数指定的,比如我们这个例子中,函数内部的 withContext 传入的 Dispatchers.IO 所指定的 IO 线程。

Dispatchers:调度器,它可以将协程限制在一个特定的线程执行,或者将它分派到一个线程池,或者让它不受限制地运行,关于 Dispatchers 这里先不展开了。
常用的 Dispatchers ,有以下三种:

Dispatchers.MainAndroid 中的主线程
• Dispatchers.IO:针对磁盘和网络 IO 进行了优化,适合 IO 密集型的任务,比如:读写文件,操作数据库以及网络请求
• Dispatchers.Default:适合 CPU 密集型的任务,比如计算

回到我们的协程,它从 suspend 函数开始脱离启动它的线程,继续执行在 Dispatchers 所指定的 IO 线程。在 suspend 函数执行完成之后,协程为我们做的最爽的事就来了:会自动帮我们把线程再切回来。我们的协程原本是运行在主线程的,当代码遇到 suspend 函数的时候,发生线程切换,根据 Dispatchers 切换到了 IO 线程;当这个函数执行完毕后,线程又切了回来,「切回来」也就是协程会帮我再 post 一个 Runnable,让我剩下的代码继续回到主线程去执行。

结论:
协程在执行到有 suspend 标记的函数的时候,会被suspend也就是被挂起,就是切个线程;不过区别在于,挂起函数在执行完成之后,协程会重新切回它原先的线程。再简单来讲,在 Kotlin 中所谓的挂起,就是一个稍后会被自动切回来的线程调度操作。

4 suspend 的意义?

随便写一个自定义的 suspend 函数:

suspend fun suspendingPrint() {
	println("Thread: ${Thread.currentThread().name}") 
	} 
System.out: Thread: main

输出的结果还是在主线程。
为什么没切换线程?因为它不知道往哪切,需要我们告诉它。

对比之前例子中 suspendingGetImage 函数代码:

suspend fun suspendingGetImage(id: String) = 
	withContext(Dispatchers.IO) {
 	...
 }

通过 withContext 源码可以知道,它本身就是一个挂起函数,它接收一个 Dispatcher 参数,依赖这个 Dispatcher 参数的指示,你的协程被挂起,然后切到别的线程。所以这个 suspend,其实并不是起到把任何把协程挂起,或者说切换线程的作用。真正挂起协程这件事,是 Kotlin 的协程框架帮我们做的。所以我们想要自己写一个挂起函数,仅仅只加上 suspend 关键字是不行的,还需要函数内部直接或间接地调用到 Kotlin 协程框架自带的 suspend 函数才行。

这个 suspend 关键字,既然它并不是真正实现挂起,那它的作用是什么?

它其实是一个提醒。

函数的创建者对函数的使用者的提醒:我是一个耗时函数,我被我的创建者用挂起的方式放在后台运行,所以请在协程里调用我。为什么 suspend 关键字并没有实际去操作挂起,但 Kotlin 却把它提供出来?因为它本来就不是用来操作挂起的。挂起的操作 —— 也就是切线程,依赖的是挂起函数里面的实际代码,而不是这个关键字。

所以suspend关键字,只是一个提醒。

你创建一个 suspend 函数但它内部不包含真正的挂起逻辑,编译器会给你一个提醒:redundant suspend modifier,告诉你这个 suspend 是多余的。所以,创建一个 suspend 函数,为了让它包含真正挂起的逻辑,要在它内部直接或间接调用 Kotlin 自带的 suspend 函数,你的这个 suspend 才是有意义的。

5 到底什么是「非阻塞式」挂起?协程真的更轻量级吗?

什么是「非阻塞式挂起」?

线程中的阻塞式:单线程情况下,在单线程下执行耗时操作是会阻塞线程的,如果在多线程情况下,那么此时的线程也是非阻塞式的。

非阻塞式是相对阻塞式而言的。 Kotlin 协程在单协程的情况下也是非阻塞式 的,因为它可以利用挂起函数来切换线程。(阻塞不阻塞,都是针对单线程讲的,一旦切了线程,肯定是非阻塞的,你都跑到别的线程了,之前的线程就自由了,可以继续做别的事情了。)即:协程可以用看起来阻塞的代码写出非阻塞式的操作

阻塞的本质?
首先,所有的代码本质上都是阻塞式的,而只有比较耗时的代码才会导致人类可感知的等待,比如在主线程上做一个耗时 50 ms 的操作会导致界面卡掉几帧,这种是我们人眼能观察出来的,而这就是我们通常意义所说的「阻塞」。举个例子,当你开发的 app 在性能好的手机上很流畅,在性能差的老手机上会卡顿,就是在说同一行代码执行的时间不一样。视频中讲了一个网络 IO 的例子,IO 阻塞更多是反映在「等」这件事情上,它的性能瓶颈是和网络的数据交换,你切多少个线程都没用,该花的时间一点都少不了。而这跟协程半毛钱关系没有,切线程解决不了的事情,协程也解决不了。

总结:

• 协程就是切线程;
• 挂起就是可以自动切回来的切线程;
• 挂起的非阻塞式指的是它能用看起来阻塞的代码写出非阻塞的操作,就这么简单。

2-《Java进阶》

一. java多线程(非常重要)

1.1. 线程

线程和进程的区别?
线程是CPU调度的最小单位,一个进程中可以包含多个线程,在Android中,一个进程通常是一个App,App中会有一个主线程,主线程可以用来操作界面元素,如果有耗时的操作,必须开启子线程执行,不然会出现ANR,除此以外,进程间的数据是独立的,线程间的数据可以共享。

java多线程实现方式主要有:

1.继承Thread

优点 : 方便传参,可以在子类添加成员变量,通过方法设置参数或构造函数传参。
缺点
1.因为Java不支持多继承,所以继承了Thread类以后,就无法继承其他类。
2.每次都要新建一个类,不支持通过线程池操作,创建和销毁线程对资源的开销比较大。
3.从代码结构上讲,为了启动一个线程任务,都要创建一个类,耦合性太高。
4.无法获取线程任务的返回结果。

Thread syncTask = new Thread() {
	    @Override
	    public void run() {
	        // 执行耗时操作
	    }
	};
	syncTask.start();//启动线程

2.实现Runnable

优点 : 此方式可以继承其他类。也可以使用线程池管理,节约资源。创建线程代码的耦合性较低。推荐使用此种方式创建线程。
缺点: 不方便传参,只能使用主线程中用final修饰的变量。其次是无法获取线程任务的返回结果。

//写法1:集成Runnable接口定义任务类
public class ThreadTask implements Runnable {

	@Override
	public void run() {
			while(true) {
			System.out.println(Thread.currentThread().getName()+" is running...");
			try {
	            Thread.sleep(1000);
	        } catch (InterruptedException e) {
	            e.printStackTrace();
	        }
		}
	}
}
//在其他地方使用
new Thread(new ThreadTask ()).start(); 

//写法2:匿名内部类写法
new Thread(new Runnable() {
	@Override
    public void run() {
        //做操作    
            
    }
}).start();

## 3.实现Callable
此种方式创建线程底层源码也是使用实现Runnable接口的方式实现的,所以不是一种新的创建线程的方式,只是在实现Runnable接口方式创建线程的基础上,同时实现了Future接口,实现有返回值的创建线程。

```java
//写法1:集成Runnable接口定义任务类
public class ThreadTask implements Runnable {

	@Override
	public void run() {
			while(true) {
			System.out.println(Thread.currentThread().getName()+" is running...");
			try {
	            Thread.sleep(1000);
	        } catch (InterruptedException e) {
	            e.printStackTrace();
	        }
		}
	}
}
//在其他地方使用
new Thread(new ThreadTask ()).start(); 

//写法2:匿名内部类写法
new Thread(new Runnable() {
	@Override
    public void run() {
        //做操作    
            
    }
}).start();

3.实现Callable

此种方式创建线程底层源码也是使用实现Runnable接口的方式实现的,所以不是一种新的创建线程的方式,只是在实现Runnable接口方式创建线程的基础上,同时实现了Future接口,实现有返回值的创建线程。

Runnable 与 Callable的区别:

1. Runnable是在JDK1.0的时候提出的多线程的实现接口,而Callable是在JDK1.5之后提出的; 
2. Runnable 接口之中只提供了一个run()方法,并且没有返回值;
3. Callable接口提供有call(),可以有返回值;

扩展:

Callable接口支持返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞主线程直到获取‘将来’结果;
当不调用此方法时,主线程不会阻塞

public class CallableImpl implements Callable<String> {
 
    public CallableImpl(String acceptStr) {
        this.acceptStr = acceptStr;
    }
 
    private String acceptStr;
 
    @Override
    public String call() throws Exception {
        // 任务阻塞 1 秒
        Thread.sleep(1000);
        return this.acceptStr + " append some chars and return it!";
    }
 
 
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<String> callable = new CallableImpl("my callable test!");
        FutureTask<String> task = new FutureTask<>(callable);
        long beginTime = System.currentTimeMillis();
        // 创建线程
        new Thread(task).start();
        // 调用get()阻塞主线程,反之,线程不会阻塞
        String result = task.get();
        long endTime = System.currentTimeMillis();
        System.out.println("hello : " + result);
        System.out.println("cast : " + (endTime - beginTime) / 1000 + " second!");
    }
}
 
//执行结果
 
hello : my callable test! append some chars and return it!
cast : 1 second!

总结:

根据Oracle提供的JAVA官方文档的说明,Java创建线程的方法只有两种方式,即继承Thread类和实现Runnable接口。其他所有创建线程的方式,底层都是使用这两种方式中的一种实现的,比如通过线程池、通过匿名类、通过lambda表达式、通过Callable接口等等,全是通过这两种方式中的一种实现的。所以我们在掌握线程创建的时候,必须要掌握的只有这两种,通过文章中优缺点的分析,这两种方法中,最为推荐的就是实现Runnable接口的方式去创建线程。

1.2. 线程的状态有哪些?

Java中定义线程的状态有6种,可以查看Thread类的State枚举:

public static enum State
  {
    NEW,  RUNNABLE,  BLOCKED,  WAITING,  TIMED_WAITING,  TERMINATED;
    
    private State() {}
  }
  • 初始(NEW):新创建了一个线程对象,还没调用start方法;
  • 运行(RUNNABLE):java线程中将就绪(ready)和运行中(running)统称为运行(RUNNABLE)。线程创建后调用了该对象的start方法,此时处于就绪状态,
    当获得CPU时间片后变为运行中状态;
  • 阻塞(BLOCKED):表现线程阻塞于锁;
  • 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断);
  • 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定时间后自行返回;
  • 终止(TERMINATED):表示该线程已经执行完毕。

状态详细说明:

  1. 初始状态(NEW)
    实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。

  2. 就绪状态(RUNNABLE之READY)
    就绪状态只是说你资格运行,调度程序没有挑选到你,你就永远是就绪状态。
    调用线程的start()方法,此线程进入就绪状态。
    当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
    当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。
    锁池里的线程拿到对象锁后,进入就绪状态。

运行中状态(RUNNABLE之RUNNING)
线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一的一种方式。

  1. 阻塞状态(BLOCKED)
    阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。

  2. 等待(WAITING)
    处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。

  3. 超时等待(TIMED_WAITING)
    处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。

  4. 终止状态(TERMINATED)
    当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
    在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

1.3. 线程的状态转换及控制

在这里插入图片描述

主要由这几个方法来控制:sleep、join、yield、wait、notify以及notifyAll。

wait() / notify() / notifyAll()

wait(),notify(),notifyAll() 是定义在Object类的实例方法,用于控制线程状态,三个方法都必须在synchronized 同步关键字所限定的作用域中调用(只能在同步控制方法或者同步控制块中使用),否则会报错 java.lang.IllegalMonitorStateException。

join() / sleep() / yield()

join()

如果线程A调用了线程B的join方法,线程A将被阻塞,等待线程B执行完毕后线程A才会被执行。这里需要注意一点的是,join方法必须在线程B的start方法调用之后调用才有意义。join方法的主要作用就是实现线程间的同步,它可以使线程之间的并行执行变为串行执行。

sleep()

当线程A调用了 sleep方法,则线程A将被阻塞,直到指定睡眠的时间到达后,线程A才会重新被唤起,进入就绪状态。


public class Test {
 
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "---" + i);
            try {
                Thread.sleep(1000);        // 阻塞当前线程1s
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

yield()  当线程A调用了yield方法,它可以暂时放弃处理器,但是线程A不会被阻塞,而是进入就绪状态。执行了yield方法的线程什么时候会继续运行由线程调度器来决定。


public class YieldThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "---" + i);
            // 主动放弃
            Thread.yield();
        }
    }
}

sleep方法和wait方法的区别是什么?

wait方法既释放cpu,又释放锁。 sleep方法只释放cpu,但是不释放锁。

sleep 方法是Thread类的一个静态方法,其作用是使运行中的线程暂时停止指定的毫秒数,从而该线程进入阻塞状态并让出处理器,将执行的机会让给其他线程。但是这个过程中监控状态始终保持,当sleep的时间到了之后线程会自动恢复。

wait 方法是Object类的方法,它是用来实现线程同步的。当调用某个对象的wait方法后,当前线程会被阻塞并释放同步锁,直到其他线程调用了该对象的 notify 方法或者 notifyAll 方法来唤醒该线程。所以 wait 方法和 notify(或notifyAll)应当成对出现以保证线程间的协调运行。

1.4. Java如何正确停止线程

注意:Java中线程的stop()、suspend()、resume()三个方法都已经被弃用,所以不再使用stop()方法停止线程。

我们只能调用线程的interrupt()方法通知系统停止线程,并不能强制停止线程。线程能否停止,何时停止,取决于系统。

1.5 线程池(非常重要)

线程池的地位十分重要,基本上涉及到跨线程的框架都使用到了线程池,比如说OkHttp、RxJava、LiveData以及协程等。

与新建一个线程相比,线程池的特点?

  1. 节省开销: 线程池中的线程可以重复利用。
  2. 速度快:任务来了就能开始,省去创建线程的时间。
  3. 线程可控:线程数量可空和任务可控。
  4. 功能强大:可以定时和重复执行任务。

ExecutorService简介

通常来说我们说到线程池第一时间想到的就是它:ExecutorService,它是一个接口,其实如果要从真正意义上来说,它可以叫做线程池的服务,因为它提供了众多接口api来控制线程池中的线程,而真正意义上的线程池就是:ThreadPoolExecutor,它实现了ExecutorService接口,并封装了一系列的api使得它具有线程池的特性,其中包括工作队列、核心线程数、最大线程数等。

线程池(ThreadPoolExecutor)中的几个参数是什么意思?

  public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {//...}


参数解释如下(重要):

corePoolSize:核心线程数量,不会释放。

maximumPoolSize:允许使用的最大线程池数量,非核心线程数量,闲置时会释放。

keepAliveTime:闲置线程允许的最大闲置时间。它起作用必须在一个前提下,就是当线程池中的线程数量超过了corePoolSize时,它表示多余的空闲线程的存活时间,即:多余的空闲线程在超过keepAliveTime时间内没有任务的话则被销毁。而这个主要应用在缓存线程池中

unit:闲置时间的单位。

workQueue:阻塞队列,用来存储已经提交但未被执行的任务,不同的阻塞队列有不同的特性。

threadFactory:线程工厂,用来创建线程池中的线程,通常用默认的即可

handler:通常叫做拒绝策略,1、在线程池已经关闭的情况下 2、任务太多导致最大线程数和任务队列已经饱和,无法再接收新的任务 。在上面两种情况下,只要满足其中一种时,在使用execute()来提交新的任务时将会拒绝,而默认的拒绝策略是抛一个RejectedExecutionException异常

上面的参数理解起来都比较简单,不过workQueue这个任务队列却要再次说明一下,它是一个BlockingQueue对象,而泛型则限定它是用来存放Runnable对象的,刚刚上面讲了,不同的线程池它的任务队列实现肯定是不一样的,所以,保证不同线程池有着不同的功能的核心就是这个workQueue的实现了,细心的会发现在刚刚的用来创建线程池的工厂方法中,针对不同的线程池传入的workQueue也不一样,五种线程池分别用的是什么BlockingQueue:

1newFixedThreadPool()>LinkedBlockingQueue  无界的队列
2newSingleThreadExecutor()>LinkedBlockingQueue  无界的队列
3newCachedThreadPool()>SynchronousQueue  直接提交的队列
4newScheduledThreadPool()>DelayedWorkQueue  等待队列
5newSingleThreadScheduledExecutor()>DelayedWorkQueue  等待队列

线程池中用到的三种阻塞队列:

  1. LinkedBlockingQueue:无界的队列

它的容量是 Integer.MAX_VALUE,为 231 -1 ,是一个非常大的值,可以认为是无界队列。FixedThreadPool 和 SingleThreadExecutor 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。

  1. SynchronousQueue:直接提交的队列

如果不希望任务在队列中等待而是希望将任务直接移交给工作线程,可使用SynchronousQueue作为等待队列。SynchronousQueue不是一个真正的队列,而是一种线程之间移交的机制。要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接收这个元素。只有在使用无界线程池或者有饱和策略时才建议使用该队列。

  1. DelayedWorkQueue:等待队列

它对应的线程池分别是 ScheduledThreadPool 和 SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。

DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构(堆的应用之一就是 优先级队列)。之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。

线程池的种类有哪些:五种功能不一样的线程池:

这样创建线程池的话,我们需要配置一堆东西,非常麻烦。所以,官方也不推荐使用这种方法来创建线程池,而是推荐使用Executors的工厂方法来创建线程池,Executors类是官方提供的一个工厂类,它里面封装好了众多功能不一样的线程池(但底层实现还是通过ThreadPoolExecutor),从而使得我们创建线程池非常的简便,主要提供了如下五种功能不一样的线程池:

newCachedThreadPool() :返回一个可以根据实际情况调整线程池中线程的数量的线程池。即该线程池中的线程数量不确定,是根据实际情况动态调整的。

newFixedThreadPool() :线程池只能存放指定数量的线程池,线程不会释放,可重复利用。

newSingleThreadExecutor() :单线程的线程池。即每次只能执行一个线程任务,多余的任务会保存到一个任务队列中,等待这一个线程空闲,当这个线程空闲了再按FIFO方式顺序执行任务队列中的任务。

newScheduledThreadPool() :可定时和重复执行的线程池。

newSingleThreadScheduledExecutor():同上。和上面的区别是该线程池大小为1,而上面的可以指定线程池的大小。

通过Executors的工厂方法来获取:

ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);
ExecutorService singleThreadPool = Executors.newSingleThreadExecutor();
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(5);
ScheduledExecutorService singleThreadScheduledPool = Executors.newSingleThreadScheduledExecutor();

通过Executors的工厂方法来创建线程池极其简便,其实它的内部还是通过new ThreadPoolExecutor(…)的方式创建线程池的,我们看一下这些工厂方法的内部实现:

  public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

线程池ThreadPoolExecutor的使用

使用线程池,其中涉及到一个极其重要的方法,即:

execute(Runnable command) 

该方法意为执行给定的任务,该任务处理可能在新的线程、已入池的线程或者正调用的线程,这由ThreadPoolExecutor的实现决定。

五种线程池使用举例:

  1. newFixedThreadPool 创建一个固定线程数量的线程池,示例为:
    创建了一个线程数为3的固定线程数量的线程池,同理该线程池支持的线程最大并发数也是3,而我模拟了10个任务让它处理,执行的情况则是首先执行前三个任务,后面7个则依次进入任务队列进行等待,执行完前三个任务后,再通过FIFO的方式从任务队列中取任务执行,直到最后任务都执行完毕。
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
    for (int i = 1; i <= 10; i++) {
        final int index = i;
        fixedThreadPool.execute(new Runnable() {
             @Override
             public void run() {
                 String threadName = Thread.currentThread().getName();
                 Log.v("zxy", "线程:"+threadName+",正在执行第" + index + "个任务");
                 try {
                        Thread.sleep(2000);
                 } catch (InterruptedException e) {
                        e.printStackTrace();
                 }
             }
         });
     }

在这里插入图片描述

  1. newSingleThreadExecutor
    创建一个只有一个线程的线程池,每次只能执行一个线程任务,多余的任务会保存到一个任务队列中,等待线程处理完再依次处理任务队列中的任务,示例为:
 ExecutorService singleThreadPool = Executors.newSingleThreadExecutor();
        for (int i = 1; i <= 10; i++) {
            final int index = i;
            singleThreadPool.execute(new Runnable() {
                @Override
                public void run() {
                    String threadName = Thread.currentThread().getName();
                    Log.v("zxy", "线程:"+threadName+",正在执行第" + index + "个任务");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
        }

在这里插入图片描述
其实我们通过newSingleThreadExecutor()和newFixedThreadPool()的方法发现,创建一个singleThreadExecutorPool实际上就是创建一个核心线程数和最大线程数都为1的fixedThreadPool。

  1. newCachedThreadPool
    创建一个可以根据实际情况调整线程池中线程的数量的线程池,为了体现该线程池可以自动根据实现情况进行线程的重用,而不是一味的创建新的线程去处理任务,我设置了每隔1s去提交一个新任务,这个新任务执行的时间也是动态变化的,示例为
 ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
        for (int i = 1; i <= 10; i++) {
            final int index = i;
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            cachedThreadPool.execute(new Runnable() {
                @Override
                public void run() {
                    String threadName = Thread.currentThread().getName();
                    Log.v("zxy", "线程:" + threadName + ",正在执行第" + index + "个任务");
                    try {
                        long time = index * 500;
                        Thread.sleep(time);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
        }

在这里插入图片描述
4. newScheduledThreadPool
创建一个可以定时或者周期性执行任务的线程池,示例为:

    ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);
        //延迟2秒后执行该任务
        scheduledThreadPool.schedule(new Runnable() {
            @Override
            public void run() {

            }
        }, 2, TimeUnit.SECONDS);
        //延迟1秒后,每隔2秒执行一次该任务
        scheduledThreadPool.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {

            }
        }, 1, 2, TimeUnit.SECONDS);

  1. newSingleThreadScheduledExecutor
    创建一个可以定时或者周期性执行任务的线程池,该线程池的线程数为1,示例为
   ScheduledExecutorService singleThreadScheduledPool = Executors.newSingleThreadScheduledExecutor();
        //延迟1秒后,每隔2秒执行一次该任务
        singleThreadScheduledPool.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                String threadName = Thread.currentThread().getName();
                Log.v("zxy", "线程:" + threadName + ",正在执行");
            }
        },1,2,TimeUnit.SECONDS);

这个和上面的没什么太大区别,只不过是线程池内线程数量的不同,效果为:每隔2秒就会执行一次该任务
在这里插入图片描述
自定义线程池ThreadPoolExecutor(自行了解)

线程池的状态:

  1. RUNNING:线程池一旦被创建,就处于 RUNNING 状态,任务数为 0,能够接收新任务,对已排队的任务进行处理。

  2. SHUTDOWN:不接收新任务,但能处理已排队的任务。调用线程池的 shutdown() 方法,线程池由 RUNNING 转变为 SHUTDOWN 状态。

  3. STOP:不接收新任务,不处理已排队的任务,并且会中断正在处理的任务。调用线程池的 shutdownNow() 方法,线程池由(RUNNING 或 SHUTDOWN ) 转变为 STOP 状态。

  4. TIDYING:SHUTDOWN 状态下,任务数为 0, 其他所有任务已终止,线程池会变为 TIDYING 状态,会执行 terminated() 方法。线程池中的 terminated() 方法是空实现,可以重写该方法进行相应的处理。
    线程池在 SHUTDOWN 状态,任务队列为空且执行中任务为空,线程池就会由 SHUTDOWN 转变为 TIDYING 状态。
    线程池在 STOP 状态,线程池中执行中任务为空时,就会由 STOP 转变为 TIDYING 状态。

  5. TERMINATED:线程池彻底终止。线程池在 TIDYING 状态执行完 terminated() 方法就会由 TIDYING 转变为 TERMINATED 状态。
    在这里插入图片描述
    线程池的停止
    关于线程池的停止,ExecutorService为我们提供了两个方法:shutdown和shutdownNow,这两个方法各有不同,可以根据实际需求方便的运用,如下:

  6. shutdown() 平滑的关闭线程池。(如果还有未执行完的任务,就等待它们执行完)。

  7. shutdownNow() 简单粗暴的关闭线程池。(没有执行完的任务也直接关闭)。
    线程池的工作流程
    在这里插入图片描述
    简单说:

  • 任务来了,优先考虑核心线程。
  • 核心线程满了,进入阻塞队列。
  • 阻塞队列满了,考虑非核心线程。
  • 非核心线程满了,再触发拒绝任务。

详细说明:

1 当一个任务通过submit或者execute方法提交到线程池的时候,如果当前池中线程数(包括闲置线程)小于coolPoolSize,则创建一个线程执行该任务。

2 如果当前线程池中线程数已经达到coolPoolSize,则将任务放入等待队列。

3 如果任务不能入队,说明等待队列已满,若当前池中线程数小于maximumPoolSize,则创建一个临时线程(非核心线程)执行该任务。

4 如果当前池中线程数已经等于maximumPoolSize,此时无法执行该任务,根据拒绝执行策略处理。

注意:当池中线程数大于coolPoolSize,超过keepAliveTime时间的闲置线程会被回收掉。回收的是非核心线程,核心线程一般是不会回收的。如果设置allowCoreThreadTimeOut(true),则核心线程在闲置keepAliveTime时间后也会被回收。

任务队列是一个阻塞队列,线程执行完任务后会去队列取任务来执行,如果队列为空,线程就会阻塞,直到取到任务。

其它面试题:
1.当线程池的核心线程数量过大或者过小的影响?

首先,多线程编程中一般线程的个数都大于CPU核心的个数,而一个CPU核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效的执行,CPU采取的策略是为了每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让其他线程使用,这个过程就属于一次上下文切换。

当线程池中核心线程数量过大时,线程与线程之间会争取CPU资源,这样就会导致上下文切换。过多的上下文切换会增加线程的执行时间,影响了整体执行的效率;

当线程池中的核心线程数量过少时,如果同一时间有大量任务需要处理,可能会导致大量任务在任务队列中排队,甚至会出现队列满了之后任务无法执行的情况,或者大量任务堆积在任务队列导致内存溢出(OOM)。

2.CPU密集型和IO密集型?

CPU密集型:比如加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务。CPU 密集型任务应配置尽可能小的线程,如配置CPU核数 + 1个线程的线程池。如果设置过多的线程数,假设设置的线程数是 CPU 核心数的 2 倍以上,因为计算任务非常重,会占用大量的 CPU 资源,所以这时 CPU 的每个核心工作基本都是满负荷的,而我们又设置了过多的线程,每个线程都想去利用 CPU 资源来执行自己的任务,这就会造成不必要的上下文切换,此时线程数的增多并没有让性能提升,反而由于线程数量过多会导致性能下降。

I/O密集型:比如数据库、文件的读写,网络通信等任务,这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。由于 IO 密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如CPU核数 * 2。因为 IO 读写速度相比于 CPU 的速度而言是比较慢的,如果我们设置过少的线程数,就可能导致 CPU 资源的浪费。而如果我们设置更多的线程数,那么当一部分线程正在等待 IO 的时候,它们此时并不需要 CPU 来计算,那么另外的线程便可以利用 CPU 去执行其他的任务,互不影响,这样的话在工作队列中等待的任务就会减少,可以更好地利用资源。

混合型任务:既包含CPU密集型又包含I/O密集型。

3.corePoolSize核心线程数,一般设置为多少?

首先要考虑到 CPU 核心数;可以使用下面的方法获取

Runtime.getRuntime().availableProcessor() 方法来获取(可能不准确,作为参考)

在确认了核心数后,再去判断是 CPU 密集型任务还是 IO 密集型任务:

  • CPU密集型:核心线程数 = CPU核数 + 1
  • IO密集型:核心线程数 = CPU核数 * 2
    注:IO密集型(某大厂实践经验)

核心线程数 = CPU核数 / (1-阻塞系数)

其中计算密集型阻塞系数为 0,IO 密集型阻塞系数接近 1,一般认为在 0.8 ~ 0.9 之间。比如 8 核
CPU,按照公式就是 2 / ( 1 - 0.9 ) = 20 个线程数

另外,可参考AsyncTask源码中的设置:

private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
    // We want at least 2 threads and at most 4 threads in the core pool,
    // preferring to have 1 less than the CPU count to avoid saturating
    // the CPU with background work
    private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
    private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;

源码有解释的:之所以 减掉这个1,是因为为了避免后台任务将 CPU 资源完全耗尽, 减掉的这个1 是留给我们 主线程 使用的。

1.6. Java锁机制

在java中,解决同步问题,很多时候都会使用到synchronized和Lock,这两者都是在多线程并发时候常使用的锁机制。在JDK1.6后,对synchronized进行了很多优化,如偏向锁、轻量级锁等,synchronized的性能已经与Reentrantlock大致相同,除非要使用Reentrantlock的一些高级功能(实现公平锁、中断锁等),一般推荐使用synchronized关键字来实现加锁机制。

Synchronized 是Java 并发编程中很重要的关键字,另外一个很重要的是 volatile。Syncronized 一次只允许一个线程进入由他修饰的代码段,从而允许他们进行自我保护。进入由Synchronized 保护的代码区首先需要获取 Synchronized 这把锁,其他线程想要执行必须进行等待。Synchronized 锁住的代码区域执行完成后需要把锁归还,也就是释放锁,这样才能够让其他线程使用。

Lock 是 Java并发编程中很重要的一个接口,它要比 Synchronized 关键字更能直译"锁"的概念,Lock需要手动加锁和手动解锁,一般通过 lock.lock() 方法来进行加锁, 通过 lock.unlock() 方法进行解锁。与 Lock 关联密切的锁有 ReetrantLockReadWriteLock

ReetrantLock 实现了Lock接口,它是一个可重入锁,内部定义了公平锁与非公平锁。

ReadWriteLock 一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作分开,分成2个锁来分配给线程,从而使得多个线程可以同时进行读操作。ReentrantReadWirteLock实现了ReadWirteLock接口,并未实现Lock接口。

Synchronized 的使用

修饰一个方法:即一次只能有一个线程进入该方法,其他线程要想在此时调用该方法,只能排队等候。

实例方法:锁住的是该类的实例对象
静态方法:锁住的是该类的类对象。
public synchronized void goHOme(){
}
public static synchronized void goHOme(){
}

修饰代码块:表示只能有一个线程进入某个代码段

public void numDecrease(Object num){
  synchronized (num){
    number++;
  }
}

修饰一个类:作用的对象是这个类的所有对象,只要是这个类型的class不管有几个对象都会起作用。

class Person {
    public void method() {
    //锁住的是该类的类对象,如果换成this或其他object,则锁住的是该类的实例对象
       synchronized(Person.class) {
          // todo
       }
    }
 }

获取对象锁

synchronized(this|object) {}
修饰非静态方法

获取类锁

synchronized(.class) {}
修饰静态方法

Lock 的使用

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;  
    boolean tryLock();  
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;  
    void unlock();  
    Condition newCondition();
}

在这里插入图片描述
使用示例:

Lock lock = ...;
lock.lock();
try{
    //处理任务
}catch(Exception ex){
     
}finally{
    lock.unlock();   //释放锁
}
Lock lock = ...;
if(lock.tryLock()) {
     try{
         //处理任务
     }catch(Exception ex){
         
     }finally{
         lock.unlock();   //释放锁
     }
}else {
    //如果不能获取锁,则直接做其他事情
}
public void method() throws InterruptedException {
    lock.lockInterruptibly();
    try {
     //.....
    }
    finally {
        lock.unlock();
    }
}

一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

注意,当一个线程获取了锁之后,是不会被interrupt()方法中断的。单独调用interrupt()方法不能中断正在运行过程中的线程,只能中断阻塞过程中的线程。因此当通过lockInterruptibly()方法获取某个锁时,如果不能获取到,只有进行等待的情况下,是可以响应中断的。而用synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。

synchronized和Lock的区别?

主要区别:

synchronizedJava中的关键字,是Java的内置实现;LockJava中的接口。
synchronized遇到异常会释放锁;Lock需要在发生异常的时候调用成员方法Lock#unlock()方法。
synchronized是不可以中断的,Lock可中断。
synchronized不能去尝试获得锁,没有获得锁就会被阻塞; Lock可以去尝试获得锁,如果未获得可以尝试处理其他逻辑。
synchronized多线程效率不如Lock,不过Java1.6以后已经对synchronized进行大量的优化,所以性能上来讲,其实差不了多少。

死锁

所谓死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

死锁触发的四大条件?

互斥锁
请求与保持
不可剥夺
循环的请求与等待

简单死锁代码示例:

public class DeadLock {
    public static String obj1 = "obj1";
    public static String obj2 = "obj2";
    public static void main(String[] args){
        Thread a = new Thread(new Lock1());
        Thread b = new Thread(new Lock2());
        a.start();
        b.start();
    }    
}
class Lock1 implements Runnable{
    @Override
    public void run(){
        try{
            System.out.println("Lock1 running");
            while(true){
                synchronized(DeadLock.obj1){
                    System.out.println("Lock1 lock obj1");
                    Thread.sleep(3000);//获取obj1后先等一会儿,让Lock2有足够的时间锁住obj2
                    synchronized(DeadLock.obj2){
                        System.out.println("Lock1 lock obj2");
                    }
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
    }
}
class Lock2 implements Runnable{
    @Override
    public void run(){
        try{
            System.out.println("Lock2 running");
            while(true){
                synchronized(DeadLock.obj2){
                    System.out.println("Lock2 lock obj2");
                    Thread.sleep(3000);
                    synchronized(DeadLock.obj1){
                        System.out.println("Lock2 lock obj1");
                    }
                }
            }
        }catch(Exception e){
            e.printStackTrace();
        }
    }
}

在这里插入图片描述

可以看到,Lock1获取obj1,Lock2获取obj2,但是它们都没有办法再获取另外一个obj,因为它们都在等待对方先释放锁,这时就是死锁。

1.7. Java中的主流锁

Java中往往是按照是否含有某一特性来定义锁,我们通过特性将锁进行分组归类:
在这里插入图片描述1.乐观锁 VS 悲观锁
概念:对于同一个数据的并发操作,乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
在这里插入图片描述
根据从上面的概念描述我们可以发现:

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
  • 调用方式示例:
    在这里插入图片描述
    悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?

乐观锁的主要实现方式 “CAS” 的技术原理:
CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 V。
  • 进行比较的值 A。
  • 要写入的新值 B。

当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。

之前提到java.util.concurrent包中的原子类,就是通过CAS来实现了乐观锁,那么我们进入原子类AtomicInteger的源码,看一下AtomicInteger的定义:
在这里插入图片描述
根据定义我们可以看出各属性的作用:

  • unsafe: 获取并操作内存的数据。
  • valueOffset: 存储value在AtomicInteger中的偏移量。
  • value: 存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的。
    接下来,我们查看AtomicInteger的自增函数incrementAndGet()的源码时,发现自增函数底层调用的是unsafe.getAndAddInt()。但是由于JDK本身只有Unsafe.class,只通过class文件中的参数名,并不能很好的了解方法的作用,所以我们通过OpenJDK 8 来查看Unsafe的源码:
    在这里插入图片描述
    根据OpenJDK 8的源码我们可以看出,getAndAddInt()循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。

后续JDK通过CPU的cmpxchg指令,去比较寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。然后通过Java代码中的while循环再次调用cmpxchg指令进行重试,直到设置成功为止。

CAS虽然很高效,但是它也存在三大问题,这里也简单说一下:

  1. ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。

  1. 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。

  2. 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。

Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

2.自旋锁 VS 适应性自旋锁

概念解析:

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

在这里插入图片描述
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

在这里插入图片描述
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

在自旋锁中另有三种常见的锁形式:TicketLock、CLHlock和MCSlock,本文中仅做名词介绍,不做深入讲解,感兴趣的同学可以自行查阅相关资料。

3.无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

这四种锁是指锁的状态,专门针对synchronized的。在介绍这四种锁状态之前还需要介绍一些额外的知识。

首先为什么Synchronized能实现线程同步?

在回答这个问题之前我们需要了解两个重要的概念:“Java对象头”、“Monitor”。

Java对象头
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?

我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

现在话题回到synchronized,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。

如同我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

通过上面的介绍,我们对synchronized的加锁机制以及相关知识有了一个了解,那么下面我们给出四种锁状态对应的的Mark Word内容,然后再分别讲解四种锁状态的思路以及特点:
在这里插入图片描述
无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

轻量级锁

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

整体的锁状态升级流程如下:
在这里插入图片描述
综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

4. 公平锁 VS 非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死,缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

直接用语言描述可能有点抽象,这里用一个例子来讲述一下公平锁和非公平锁。
在这里插入图片描述
如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。
但是对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。如下图所示
在这里插入图片描述
接下来我们通过ReentrantLock的源码来讲解公平锁和非公平锁。
在这里插入图片描述
根据代码可知,ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
下面我们来看一下公平锁与非公平锁的加锁方法的源码:
在这里插入图片描述
通过上图中的源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。
在这里插入图片描述
再进入hasQueuedPredecessors(),可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。
综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

5. 可重入锁 VS 非可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:
在这里插入图片描述
在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。

如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。

而为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?我们通过图示和源码来分别解析一下。

还是打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。
在这里插入图片描述
但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。
在这里插入图片描述
之前我们说过ReentrantLock和synchronized都是重入锁,那么我们通过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。

当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。

释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
在这里插入图片描述
6. 独享锁 VS 共享锁

独享锁和共享锁同样是一种概念。我们先介绍一下具体的概念,然后通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

下图为ReentrantReadWriteLock的部分源码:
在这里插入图片描述
我们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。

那读锁和写锁的具体加锁方式有什么区别呢?在了解源码之前我们需要回顾一下其他知识。

在最开始提及AQS的时候我们也提到了state字段(int类型,32位),该字段用来描述有多少线程获持有锁。

在独享锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。如下图所示:
在这里插入图片描述

  • 这段代码首先取到当前锁的个数c,然后再通过c来获取写锁的个数w。因为写锁是低16位,所以取低16位的最大值与当前的c做与运算( int w
    = exclusiveCount©; ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
  • 在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
  • 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
  • 如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
  • 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!
    tryAcquire()除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。

因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。

接着是读锁的代码:
在这里插入图片描述

可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。

此时,我们再回头看一下互斥锁ReentrantLock中公平锁和非公平锁的加锁源码:
在这里插入图片描述
我们发现在ReentrantLock虽然有公平锁和非公平锁两种,但是它们添加的都是独享锁。根据源码所示,当某一个线程调用lock方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。而如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。所以可以确定ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁。

1.8. Java中Volatile关键字(重要)

基本概念:Java 内存模型中的可见性、原子性有序性

原子性:(原子是世界上的最小单位,具有不可分割性)原子性就是指该操作是不可再分的。不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。比如 a = 1;

非原子性:

也就是整个过程中会出现线程调度器中断操作的现象

类似"a ++"这样的操作不具有原子性,因为它可能要经过以下两个步骤:

(1)取出 a 的值

(2)计算 a+1

如果有两个线程t1,t2都在进行这样的操作。t1在第一步做完之后还没来得及加1操作就被线程调度器中断了,于是t2开始执行,t2执行完毕后t1开始执行第二步(此时t1中a的值可能还是旧值,不是一定的,只有线程t2中a的值没有及时更新到t1中才会出现)。这个时候就出现了错误,t2的操作相当于被忽略了

类似于a += 1这样的操作都不具有原子性。还有一种特殊情况,就是long跟double类型某些情况也不具有原子性

只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

举例:请分析以下哪些操作是原子性操作:

x = 10;        //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4

其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。

语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。

同样的,x++和 x = x+1包括2个操作:读取x的值,进行加1操作,写入新的值。

如何保证原子性?

synchronizedLock、cas原子类工具

由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。其次cas原子类工具。

共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这个几个线程的共享变量

可见性:一个线程对共享变量值的修改,能够及时的被其它线程看到。也就是一个线程对共享变量修改的结果,另一个线程马上就能看到修改的值。

如何保证可见性?

volatilesynchronizedLock

要想实现变量的一定可见,可以使用volatile。另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。(其实还有final,但是它初始化后,值不可更改,所以一般不用它实现可见性)。

指令重排:CPU在执行代码时,其实并不一定会严格按照我们编写的顺序去执行,而是可能会考虑一些效率方面的原因,对那些先后顺序无关紧要的代码进行重新排序,这个操作就被称为指令重排。指令重排在单线程情况下没有什么影响,但是在多线程就不一定了。

有序性:程序执行的顺序按照代码先后的顺序执行。

如何保证有序性?

volatilesynchronizedLock

volatile:

volatile原理:Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
在这里插入图片描述
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU cache 中。

而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

当一个变量定义为 volatile 之后,将具备两种特性:

1.保证此变量对所有的线程的可见性。当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主内存是不确定的,当其他线程去读取时,此时主内存中可能还是原来的旧值,因此无法保证可见性。

2.禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。

volatile 性能:
  volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
  
volatile为什么不能保证原子性?
简单的说,修改volatile变量分为四步:

1)读取volatile变量到local
2)修改变量值
3)local值写回
4)插入内存屏障,即lock指令,让其他线程可见

这样就很容易看出来,前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改。原子性需要锁来保证。(或者可以理解为线程安全需要锁来保证)。这也就是为什么,volatile只用来保证变量可见性和有序性,但不保证原子性。

1.9.synchronized同步原理

数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM,而j.u.c.Lock给出的答案是在硬件层面依赖特殊的CPU指令。

package com.paddx.test.concurrent;
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

反编译结果:

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

2 JVM(java虚拟机)

2.1. 运行时数据区域

在这里插入图片描述
1.Jvm内存区域(运行时数据区)划分:

程序计数器:当前线程的字节码执行位置的指示器。内存空间小,线程私有。如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined)。此内存区域是唯一 一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

Java虚拟机栈:线程私有,生命周期和线程一致。描述的Java方法执行的内存模型,每个方法在执行的同时会创建一个栈帧(Stack Frame),存储着局部变量、操作数栈、动态链接和方法出口等。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

局部变量表:存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)

StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。
OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。

本地方法栈:区别于 Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈为虚拟机使用到的Native方法服务。也会有StackOverflowError和 OutOfMemoryError 异常。

Java堆:所有对象实例分配的区域。对于绝大多数应用来说,这块区域是 JVM 所管理的内存中最大的一块。线程共享**,主要是存放对象实例和数组。**内部会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。可以位于物理上不连续的空间,但是逻辑上要连续。

OutOfMemoryError:如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。

方法区:所有已经被虚拟机加载的类的信息、常量、静态变量和即时编辑器编译后的代码数据

详细说明:

程序计数器
程序计数器(Program Counter Register) 是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。

因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。
如果线程正在执行的是一个 Native 方法,这个计数器值则为空(Undefined)。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java 虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等消息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。

其中 64 位长度的 long 和 double 类型的数据会占用两个局部变量空间(Slot),其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在 Java 虚拟机规范中,对这个区域规定了两种异常状态:

如果线程请求的栈深度大于虚拟机所允许的的深度,将抛出 StackOverflowError 异常。
如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

本地方法栈
本地方法栈(Native Method Stack) 与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。

在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(例如:Sun HotSpot虚拟机)直接就把虚拟机栈和本地方法栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

Java 堆
对于大多数应用来说,Java 堆(Java Heap) 是 Java 虚拟机所管理的的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的主要区域,从内存回收的角度来看,由于现在收集器基本采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。

从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

方法区
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

运行时常量池(Runtime Constant Pool) 是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时就会抛出 OutOfMemoryError 异常。

扩展:String s1 = "abc"和String s2 = new String(“abc”)的区别,生成对象的情况

指向方法区:"abc"是常量,所以它会在方法区中分配内存,如果方法区已经给"abc"分配过内存,则s1会直接指向这块内存区域。

指向Java堆:new String(“abc”)是重新生成了一个Java实例,它会在Java堆中分配一块内存。

2.1. GC机制(重要)

GC 是 garbage collection 的缩写, 垃圾回收的意思. 也可以是 Garbage Collector, 也就是垃圾回收器.
Java的内存分配与回收全部由JVM垃圾回收进程自动完成。

面试题:“你能不能谈谈,java GC”

1、哪些对象可以被回收。
2、何时回收这些对象。
3、采用什么样的方式回收。

问题1:哪些对象可以被回收?

对象存活判断(如何判断对象可回收/垃圾搜集)
判断一个对象可以回收通常采用的算法是**引用计数算法和可达性分析算法。**由于互相引用导致的计数不好判断,Java采用的可达性算法。

  1. 引用计数算法
    每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,效率很高,但是主流的JVM并没有选用这种算法来判定可回收对象,因为它有一个致命的缺陷,那就是它无法解决对象之间相互循环引用的的问题,对于循环引用的对象它无法进行回收。例:
public class Object {
    public Object instance;
    public static void main(String[] args) {
        // 1
        Object objectA = new Object();
        Object objectB = new Object();
        
        // 2
        objectA.instance = objectB;
        objectB.instance = objectA;
        
        // 3
        objectA = null;
        objectB = null;
    }

程序启动后,objectA和objectB两个对象被创建并在堆中分配内存,这两个对象都相互持有对方的引用,除此之外,这两个对象再无任何其他引用,实际上这两个对象已经不可能再被访问(引用被置空,无法访问),但是它们因为相互引用着对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知GC收集器回收它们。

实际上,当第1步执行时,两个对象的引用计数器值都为1;当第2步执行时,两个对象的引用计数器都为2;当第3步执行时,二者都清为空值,引用计数器值都变为1。根据引用计数算法的思想,值不为0的对象被认为是存活的,不会被回收;而事实上这两个对象已经不可能再被访问了,应该被回收。

  1. 可达性分析算法(根搜索算法)
    在主流的JVM实现中,都是通过可达性分析算法来判定对象是否存活的。可达性分析算法的基本思想是:通过一系列被称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots对象没有任何引用链相连,就认为GC Roots到这个对象是不可达的,判定此对象为不可用对象,可以被回收。
    在这里插入图片描述
    在上图中,objectA、objectB、objectC是可达的,不会被回收;objectD、objectE虽然有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

在Java中,可作为GC Roots的对象包括下面几种:

  1. 虚拟机栈中引用的对象
    虚拟机栈中的引用的对象可以作为GC Root。我们程序在虚拟机的栈中执行,每次函数调用调用都是一次入栈。在栈中包括局部变量表和操作数栈,局部变量表中的变量可能为引用类型(reference),他们引用的对象即可作为GC Root。不过随着函数调用结束出栈,这些引用便会消失。

  2. 方法区中类静态属性引用的对象
    简单的说就是我们在类中使用的static声明的引用类型字段,例如:

Class Dog {
    private static Object tail;
} 
  1. 方法区中常量引用的对象
    简单的说就是我们在类中使用final声明的引用类型字段,例如:
Class Dog {
    private final Object tail;
} 
  1. 本地方法栈中引用的对象
    就是程序中native本地方法引用的对象。

问题3:采用什么样的方式回收
GC常用算法

可达性分析算法只是知道了哪些对象可以回收,不过垃圾收集显然还需要解决后两个问题,什么时候回收以及如何回收,在根搜索算法的基础上,现代虚拟机的实现当中,垃圾搜集的算法主要有三种,分别是标记-清除算法、复制算法、标记-整理算法,这三种算法都扩充了根搜索算法,不过它们理解起来还是非常好理解的。

标记 -清除算法

就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高(递归与全堆对象遍历);另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,内存的布局自然会乱七八糟。空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法
“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。

标记-整理算法
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。

分代搜集算法(重要)
GC 分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。

“分代搜集”算法,把Java堆分为新生代老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

新生代GC(minor GC):指发生在新生代的垃圾回收动作,因为Java对象大多都具备朝生夕灭的特点,所以minor GC发生得非常频繁,一般回收速度也比较块。

老年代GC(Major GC/Full GC):指发生在老年代的GC,它的速度会比minor GC慢很多。

**问题2:何时回收这些对象?

回收的时机**
JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。因此GC按照回收的区域又分了两种类型,一种是普通GC(minor GC),一种是全局GC(major GC or Full GC),它们所针对的区域如下。普通GC(minor GC):只针对新生代区域的GC。全局GC(major GC or Full GC):针对年老代的GC,偶尔伴随对新生代的GC以及对永久代的GC。由于年老代与永久代相对来说GC效果不好,而且二者的内存使用增长速度也慢,因此一般情况下,需要经过好几次普通GC,才会触发一次全局GC。

内存模型与回收策略
在这里插入图片描述
Java 堆(Java Heap)是JVM所管理的内存中最大的一块,堆又是垃圾收集器管理的主要区域,Java 堆主要分为2个区域-新生代与老年代,其中年轻代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 2个区。

Eden 区
大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。 通过 Minor GC 之后,Eden 会被清空,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入 Old 区)。

Survivor 区
Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,类似于我们交通灯中的黄灯。Survivor 又分为2个区,一个是 From 区,一个是 To 区。每次执行 Minor GC,会将 Eden 区和 From 存活的对象放到 Survivor 的 To 区(如果 To 区不够,则直接进入 Old 区)。Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历16次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。

Old 区
老年代占据着2/3的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记——整理算法。

java垃圾收集器:(共7种,着重了解CMS和G1)

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)

G1收集器

与CMS收集器相比G1收集器有以下特点:

1、空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。

2、可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。

使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region 的集合。

G1的新生代收集跟 ParNew 类似,当新生代占用达到一定比例的时候,开始出发收集。和 CMS 类似,G1 收集器收集老年代对象会有短暂停顿。

2.3. 类加载过程

类加载的时机:

  • 隐式加载:new创建类的实例
  • 显式加载:loaderClass,forName等
  • 操作类的静态变量(使用或赋值)
  • 调用类的静态方法
  • 使用反射方式创建某个类或者接口对象的Class对象。
  • 初始化某个类的子类
  • 直接使用java.exe命令来运行某个主类

类的加载过程:
在这里插入图片描述
其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。解析阶段可以在初始化之后再开始(运行时绑定或动态绑定或晚期绑定)。

  1. 加载:ClassLoader通过一个类的完全限定名查找此类字节码文件,并利用字节码文件创建一个class对象。

  2. 验证:对类的验证。目的在于确保class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身的安全**,主要包括四种验证:文件格式的验证,元数据的验证,字节码验证,符号引用验证。**

  3. 准备:为类变量分配内存并设置初始值。
    为类变量(static修饰的字段变量)分配内存并且设置该类变量的初始值,(如static int i = 5 这里只是将 i 赋值为0,在初始化的阶段再把 i 赋值为5),这里不包含final修饰的static ,因为final在编译的时候就已经分配了。这里不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象分配到Java堆中。

  4. 解析:将常量池的符号引用转化为直接引用。

  5. 初始化:这里是类加载的最后阶段,前面过程都是以虚拟机主导,而初始化阶段开始执行类中定义的Java程序代码,包括类变量的赋值动作和构造函数的赋值。
    如果该类具有父类就进行对父类进行初始化,执行其静态初始化器(静态代码块)和静态初始化成员变量。(前面已经对static 初始化了默认值,这里我们对它进行赋值,成员变量也将被初始化)

使用:

卸载

只有加载、验证、准备、初始化和卸载的这个五个阶段的顺序是确定的。

2.4. 双亲委派模型

类加载的机制,以及为什么要这样设计?

类加载的机制是双亲委派模型。大部分Java程序需要使用的类加载器包括:

  • 启动类加载器:由C++语言实现,负责加载Java中的核心类。
  • 扩展类加载器:负责加载Java扩展的核心类之外的类。
  • 应用程序类加载器:负责加载用户类路径上指定的类库
    双亲委派模型如下:
    在这里插入图片描述
    双亲委派模型要求出了顶层的启动类加载器之外,其他的类加载器都有自己的父加载器,通过组合实现。

双亲委派模型的工作流程/原理:

简单说:
当一个类加载的任务来临的时候,先交给父类加载器完成,父类加载器交给父父类加载器完成,知道传递给启动类加载器,如果完成不了的情况下,再依次往下传递类加载的任务。

详细解释:
如果一个类收到了类加载的请求,它并不会自己先去加载,而是把这个请求委托给父类加载器去执行,如果父类加载器还存在父类加载器,则进一步向上委托,依次递归,请求最后到达顶层的启动类加载器,如果父类能够完成类的加载任务,就会成功返回,倘若父类加载器无法完成任务,子类加载器才会尝试自己去加载,这就是双亲委派模式。就是每个儿子都很懒,遇到类加载的活都给它爸爸干,直到爸爸说我也做不来的时候,儿子才会想办法自己去加载。

双亲委派模型的优势?这样设计的原因?:

简单说:
双亲委派模型能够保证Java程序的稳定运行,不同层次的类加载器具有不同优先级,所有的对象的父类Object,无论哪一个类加载器加载,最后都会交给启动类加载器,保证安全。

详细解释:
采用双亲委派模式的好处就是Java类随着它的类加载器一起具备一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载,当父亲已经加载了该类的时候,就没有必要子类加载器(ClassLoader)再加载一次。其次是考虑到安全因素,Java核心API中定义类型不会被随意替换,假设通过网路传递一个名为java.lang.Integer的类,通过双亲委派的的模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字类,发现该类已经被加载,并不会重新加载网络传递过来的java.lang.Integer.而之际返回已经加载过的Integer.class,这样便可以防止核心API库被随意篡改。可能你会想,如果我们在calsspath路径下自定义一个名为java.lang.SingInteger?该类并不存在java.lang中,经过双亲委托模式,传递到启动类加载器中,由于父类加载器路径下并没有该类,所以不会加载,将反向委托给子类加载器,最终会通过系统类加载器加载该类,但是这样做是

java.lang.SecurityException:Prohibited package name: java.lang

为什么叫双亲委派?
parents delegate

parents在英文中是“父母”、“双亲”的意思,但其实表达的是“父母这一辈”的人的意思。实际上这个模型中,只是表达“父母这一辈”的class loader而已,并不是说真的有一个父亲的class loader和一个母亲class loader。

简单来说,就是翻译的人,不仅英语不好,而且也不理解jvm的类加载机制,才会导致翻译成这样

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。