Java多线程深度理解

xiaoxiao2021-02-28  70

深入理解多线程

                                          -----作者华

(一)首先了解一下Java的虚拟机是如何执行线程同步的:

的Java的语言要想被JVM执行,需要被转换成由字节码组成的类文件。首先就来分析一下的Java的虚拟机是如何在字节码层面上执行线程同步的。

线程和共享数据

的Java的编程语言的优点之一是它在语言层面上对多线程的支持这种支持大部分集中在协调多个线程对共享数据的访问上.JVM的内存结构主要包含以下几个重要的区域:栈,堆,方法区等。

的Java的虚拟机中,每个线程独享一块栈内存,其中包括局部变量,线程调用的每个方法的参数和返回值。其他线程无法读取到该栈内存块的数据。栈中的数据仅限于基本类型和对象引用。所以,JVM中,栈上是无法保存真实的对象的,只能保存对象的引用。真正的对象要保存在堆中。

在JVM中,堆内存是所有线程共享的。堆中只包含对象,没有其他东西。所以,堆上也无法保存基本类型和对象引用。堆和栈分工明确。但是,对象的引用其实也是对象的一部分。这里值得一提的是,数组是保存在堆上面的,即使是基本类型的数据,也是保存在堆中的。因为在Java的的中,数组是对象。 

除了栈和堆,还有一部分数据可能保存在JVM中的方法区中,比如类的静态变量。方法区和栈类似,其中只包含基本类型和对象应用。和栈不同的是,方法区中的静态变量可以被所有线程访问到。

对象和类的锁

如前文提到,JVM中的两块内存区域可以被所有线程共享:

 那么,如果有多个线程想要同时访问同一个对象或者静态变量,就需要被管控,否则可能出现不可预期的结果。

为了协调多个线程之间的共享数据访问,虚拟机给每个对象和类都分配了一个锁。这个锁就像一个特权,在同一时刻,只有一个线程可以“拥有”这个类或者对象。如果一个线程想要获得某个类或者对象的锁,需要询问虚拟机。当一个线程向虚拟机申请某个类或者对象的锁之后,也许很快或者也许很慢虚拟机可以把锁分配给这个线程,同时这个线程也许永远也无法获得锁。当线程不再需要锁的时候,他再把锁还给虚拟机。这时虚拟机就可以再把锁分配给其他申请锁的线程。

类锁其实通过对象锁实现的。因为当虚拟机加载一个类的时候,会会为这个类实例化一个java.lang.Class中对象,当你锁住一个类的时候,其实锁住的是其对应的类对象。

显示器

JVM与监视器一起使用锁。监视器基本上是一个监视器,它监视一系列代码,确保一次只有一个线程执行代码。 

每个监视器都与一个对象引用关联。当一个线程到达监视器监视下的一段代码中的第一条指令时,该线程必须获得对该引用对象的锁定。直到它获得锁定,线程才被允许执行代码。一旦获得锁定,线程将进入受保护代码块。 

当线程离开块时,不管它如何离开块,它释放关联对象上的锁。

多个锁

单个线程被允许多次锁定相同的对象。对于每个对象,JVM都会维护对象被锁定的次数。解锁对象的计数为零。当一个线程第一次获得锁定时,计数递增到1每次线程获取对同一对象的锁定时,计数都会递增。每次线程释放锁定时,计数都会递减。当计数达到零时,锁被释放并可供其他线程使用。

同步块

在的的Java语言术语中,必须访问共享数据的多个线程的协调称为同步该语言提供了两种内置的方式来同步对数据的访问:同步语句或同步方法。

同步语句

要创建一个同步语句,您可以使用同步的关键字和一个表达式来评估对象引用,如reverseOrder()下面的方法:

在上面的例子中,包含在synchronizedblock中的语句将不会被执行,直到在当前对象(this)上获取一个锁。如果不是这个引用,则表达式产生另一个对象的引用,线程继续之前将获取与该对象关联的锁。 

两个操作码monitorentermonitorexit,用于方法中的同步块,如下表所示。

表1.监视器

monitorenter Java虚拟机遇到它时,它会获取堆栈上由objectref引用的对象的锁。如果线程已经拥有该对象的锁,则计数会递增。每次monitorexit为对象上的线程执行时,计数都会解减。当计数达到零时,显示器被释放。

 

看看这个类的方法产生的字节码序列.reverseOrder()KitchenSync 

请注意,即使异步从同步块中抛出,抓子句也可以确保锁定的对象将被解锁。无论如何退出同步块,当线程进入该块时获取的对象锁定将被释放。 

同步方法

要同步整个方法,只需将该同步关键字包含为方法限定符之一,如下所示:

JVM不使用任何特殊的操作码来调用或从同步方法返回。当JVM解析对方法的符号引用时,它将确定该方法是否同步。如果是,则在调用方法之前,JVM获取一个锁。对于实例方法,JVM获取与调用该方法的对象关联的锁。对于类方法,它获取与该方法所属的类关联的锁。在同步方法完成后,无论是通过返回还是通过抛出异常完成,都会释放该锁。 

(二)同步的实现原理

同步,是的Java中的解决用于并发情况下数据同步访问的一个很重要的关键字。当我们想要保证一个共享资源在同一时间只会被一个线程访问到时,我们可以在代码中使用同步关键字对类或者对象加锁。 

反编译

众所周知,在Java的的中,同步有两种使用形式,同步方法和同步代码块代码如下:

先用的Java的-P来反编译以上代码,结果如下

编译期后,我们可以看到的Java的编译器为我们生成的字节码。doSth状语从句:doSth1。的处理上稍有不同也就是说.JVM对于同步方法和同步代码块的。

对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。对于同步代码块.JVM采用monitorentermonitorexit两个指令来实现同步。

方法级的同步是隐式的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

同步代码块

同步代码块使用monitorenter状语从句:monitorexit两个指令实现。 

可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。每个对象维护着一个记录着被被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁执行monitorenter)后,该计数器自增变为1,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。为0的时候,锁将被释放,其他线程便可以获得锁。

(三)      Java 虚拟机的锁化技术

高效并发是从JDK 1.5到JDK 1.6的一个重要改进,HotSpot虚拟机开发团队在这个版本中花费了很大的精力去对Java中的锁进行优化,如适应性自旋,锁消除,锁粗化,轻量级锁和偏向锁等。这些技术都是为了在线程之间更高效的共享数据,以及解决竞争问题。 

本文,主要先来介绍一下自旋,锁消除以及锁粗化等技术。

这里简单说明一下,本文要介绍的这几个概念,以及后面要介绍的轻量级锁和偏向锁,其实对于使用他的开发者来说是屏蔽掉了的,也就是说,作为一个Java的的开发,你只需要知道你想在加锁的时候使用同步就可以了,具体的锁的优化是虚拟机根据竞争情况自行决定的。 

也就是说,在JDK 1.5以后,我们即将介绍的这些概念,都被封装在synchronized中了。 

线程状态

要想把锁说清楚,一个重要的概念不得不提,那就是线程和线程的状态。锁和线程的关系是怎样的呢,举个简单的例子你就明白了。 

比如,你今天要去银行办业务,你到了银行之后,要先取一个号,然后你坐在休息区等待叫号,过段时间,广播叫到你的号码之后,会告诉你去哪个柜台办理业务,这时,你拿着你手里的号码,去到对应的柜台,找相应的柜员开始办理业务。当你办理业务的时候,这个柜台和柜台后面的柜员只能为你自己服务。当你办完业务离开之后,广播再喊其他的顾客前来办理业务。

 

这个例子中,每个顾客是一个线程。柜台前面的那把椅子,就是锁。柜台后面的柜员,就是共享资源。你发现无法直接办理业务,要取号等待的过程叫做阻塞。叫你的号码的时候,你起身去办业务,这就是唤醒。当你坐在椅子上开始办理业务的时候,你就获得锁。当你办完业务离开的时候,你就释放锁。

对于线程来说,一共有五种状态,分别为:初始状态(新),就绪状态(可运行),运行状态(运行),阻塞状态(阻塞)和死亡状态(死)。

 

 

自旋锁

在前一篇文章中,我们介绍的同步的实现方式中使用监视器进行加锁,这是一种互斥锁,为了表示他对性能的影响我们称之为重量级锁。 

这种互斥锁在互斥同步上对性能的影响很大,爪哇的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到内核态,因此状态转换需要花费很多的处理器时间。 

就像去银行办业务的例子,当你来到银行,发现柜台前面都有人的时候,你需要取一个号,然后再去等待区等待,一直等待被叫号。这个过程是比较浪费时间的,那么有没有什么办法改进呢?

有一种比较好的设计,那就是银行提供自动取款机,当你去银行取款的时候,你不需要取号,不需要去休息区等待叫号,你只需要找到一台取款机,排在其他人后面等待取款就行了。

 

之所以能这样做,是因为取款的这个过程相比较之下是比较节省时间的。如果所有人去银行都只取款,或者办理业务的时间都很短的话,那也就可以不需要取号,不需要去单独的休息区,不需要听叫号,也不需要再跑到对应的柜台了。

而,在程序中,爪哇的虚拟机的开发工程师们在分析过大量数据后发现:共享数据的锁定状态一般只会持续很短的一段时间,为了这段时间去挂起和恢复线程其实并不值得。

如果物理机上有多个处理器,可以让多个线程同时执行的话,我们就可以让后面来的线程“稍微等一下”,但是并不放弃处理器的执行时间,看看持有锁的线程会不会很快释放锁。这个“稍微等一下”的过程就是自旋。

自旋锁在JDK 1.4中已经引入,在JDK1.6中默认开启。

很多人在对于自旋锁的概念不清楚的时候可能会有以下疑问:这么听上去,自旋锁好像和阻塞锁没啥区别,反正都是等着嘛。

对于去银行取钱的你来说,站在取款机面前等待和去休息区等待叫号有一个很大的区别:

那就是如果你在休息区等待,这段时间你什么都不需要管,随意做自己的事情,等着被唤醒就行了。 

如果你在取款机面前等待,那么你需要时刻关注自己前面还有没有人,因为没人会唤醒你。

很明显,这种直接去取款机前面排队取款的效率是比较高。

所以呢,自旋锁和阻塞锁最大的区别就是,到底要不要放弃处理器的执行时间。对于阻塞锁和自旋锁来说,都是要等待获得共享资源。但是阻塞锁是放弃了CPU时间,进入了等待区,等待被唤醒。而自旋锁是一直“自旋”在那里,时刻的检查共享资源是否可以被访问。

由于自旋锁只是将当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快。但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。如果线程竞争不激烈,并且保持锁的时间段。适合使用自旋锁。

锁消除

除了自旋锁之后,JDK中还有一种锁的优化被称之为锁消除。还拿去银行取钱的例子说。

你去银行取钱,所有情况下都需要取号,并且等待吗?其实是不用的,当银行办理业务的人不多的时候,可能根本不需要取号,直接走到柜台前面办理业务就好了。

 

 

能这么做的前提是,没有人和你抢着办业务。

上面的这种例子,在锁优化中被称作“锁消除”,是JIT编译器对内部锁的具体实现所做的一种优化。

在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(逃逸分析)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。

如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。

如以下代码:

 

代码中对霍利斯这个对象进行加锁,但是霍利斯对象的生命周期只在f()的的方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉优化成:

 

这里,可能有读者会质疑了,代码是程序员自己写的,程序员难道没有能力判断要不要加锁吗?就像以上代码,完全没必要加锁,有经验的开发者一眼就能看的出来的。其实道理是这样,但是还是有可能有疏忽,比如我们经常在代码中使用的StringBuffer的作为局部变量,而StringBuffer的的中的追加是线程安全的,有同步修饰的,这种情况开发者可能会忽略。这时候,JIT就可以帮忙优化,进行锁消除。

了解我的朋友都知道,一般到这个时候,我就会开始反编译,然后拿出反编译之后的代码来证明锁优化确实存在。

但是,之前很多例子之所以可以用反编译工具,是因为那些“优化”,如语法糖等,是在javac的的编译阶段发生的,并不是在JIT编译阶段发生的。而锁优化,是JIT编译器的功能,所以,无法使用现有的反编译工具查看具体的优化结果。(关于javac的的编译和JIT编译的关系和区别,我在我的知识星球中单独发了一篇文章介绍。) 

但是,如果读者感兴趣,还是可以看的,只是会复杂一点,首先你要自己构建一个的的FastTesT版本的JDK,然后在使用的Java的命令对的的.class文件进行执行的时候加上-XX:+ PrintEliminateLocks参数。而且JDK的模式还必须是服务器模式。

总之,读者只需要知道,在使用同步的时候,如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除。 

锁粗化

很多人都知道,在代码中,需要加锁的时候,我们提倡尽量减小锁的粒度,这样可以避免不必要的阻塞。 

这也是很多人原因是用同步代码块来代替同步方法的原因,因为往往他的粒度会更小一些,这其实是很有道理的。

还是我们去银行柜台办业务,最高效的方式是你坐在柜台前面的时候,只办和银行相关的事情。如果这个时候,你拿出手机,接打几个电话,问朋友要往哪个账户里面打钱,这就很浪费时间了。最好的做法肯定是提前准备好相关资料,在办理业务时直接办理就好了。

 

 

加锁也一样,把无关的准备工作放到锁外面,锁内部只处理和并发相关的内容。这样有助于提高效率。 

那么,这和锁粗化有什么关系呢?可以说,大部分情况下,减小锁的粒度是很正确的做法,只有一种特殊的情况下,会发生一种叫做锁粗化的优化。

就像你去银行办业务,你为了减少每次办理业务的时间,你把要办的五个业务分成五次去办理,这反而适得其反了。因为这平白的增加了很多你重新取号,排队,被唤醒的时间。 

如果在一段代码中连续的对同一个对象反复加锁解锁,其实是相对耗费资源的,这种情况可以适当放宽加锁的范围,减少性能消耗。 

当JIT发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同步的范围扩散(粗化)到整个操作序列的外部。 

如以下代码:

会被粗化成:

这其实和我们要求的减小锁粒度并不冲突。减小锁粒度强调的是不要在银行柜台前做准备工作以及和办理业务无关的事情。而锁粗化建议的是,同一个人,要办理多个业务的时候,可以在同一个窗口一次性办完,而不是多次取号多次办理。                                                          

总结

自Java 6开始,Java虚拟机对内部锁的实现进行了一些优化。这些优化主要包括锁消除(Lock Elision),锁粗化(Lock Coarsening),偏向锁(Biased Locking)以及适应性自旋锁(自适应锁定)。这些优化仅在Java虚拟机服务器模式下起作用(即运行Java程序时我们可能需要在命令行中指定Java虚拟机参数“-server”以开启这些优化)。 

本文主要介绍了自旋锁,锁粗化和锁消除的概念。在JIT编译过程中,虚拟机会根据情况使用这三种技术对锁进行优化,目的是减少锁的竞争,提升性能。 

(四)      代码阶段

1. 线程的优先级:

每一个Java的线程都有一个优先级,这样有助于操作系统确定线程的调度顺序。

Java线程的优先级是一个整数,其取值范围是1(Thread.MIN_PRIORITY) - 10(Thread.MAX_PRIORITY)。 

默认情况下,每一个线程都会分配一个优先级NORM_PRIORITY(5)。 

具有较高优先级的线程对程序更重要,并且应该在低优先级的线程之前分配处理器资源。但是,线程优先级不能保证线程执行的顺序,而且非常依赖于平台。 

2.创建一个线程:三种方式

⑴继承线程类创建线程类

①定义线程类的子类,并重写该类的运行方法,该运行方法的方法体就代表了线程要完成的任务。因此把运行()的方法称为执行体。

②创建线程子类的实例,即创建了线程对象。

③调用线程对象的开始()方法来启动线程。

(二)通过可运行接口创建线程类

①定义可运行的接口的实现类,并重写该接口的运行()的方法,该运行()方法的方法体同样是该线程的线程执行体。

②创建可运行实现类的实例,并依此实例作为线程的目标来创建主题对象,该螺纹对象才是真正的线程对象。

③调用线程对象的开始()方法来启动该线程。

(三)通过可赎回和未来创建线程

①创建可赎回接口的实现类,并实现呼叫()方法,该呼叫()方法将作为线程执行体,并且有返回值。

②创建可赎回实现类的实例,使用FutureTask类来包装可赎回对象,该FutureTask对象封装了该可赎回对象的呼叫()方法的返回值。

④使用FutureTask对象作为线程对象的目标创建并启动新线程

调用FutureTask对象的get()方法方法方法来获得子线程执行结束后的返回值

3.创建线程的三种方式的对比

(1)采用实现Runnable,Callable接口的方式创建多线程时,线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。

(2)使用继承Thread.currentThread()方法,直接使用这个即可获得当前线程,如果需要访问当前线程,则需要访问当前线程。

(五)资料

1.40 个的Java的多线程总结

https://juejin.im/entry/58f1d35744d904006cf14b17

2. 多线程在什么情况下使用

https://www.zhihu.com/question/65200684

 

 

 

转载请注明原文地址: https://www.6miu.com/read-1949986.html

最新回复(0)