深入理解系列之JAVA多线程(1)——概念与原理机制

现代操作系统都支持多任务的处理,所以为了提高JAVA程序运行的效率,JAVA在虚拟机层面采用了多线程机制,即“同时”运行多个逻辑代码!

问题一、线程和进程的区别是什么?

在操作系统层面,我们听到最多的就是“进程”的概念,即在日常操作系统使用中,我们认为每个软件就是一个进程,线程被称作“轻量级的进程“,我们通常用以下三句话(或表达同类意思的语句)来阐述他们之间的关系:

1)、进程是内核资源分配的最小单位;
2)、线程是内核调度的最小的单位;
3)、一个进程包含多个线程,同一进程下的多个线程共享同一内存空间(但拥有自己的栈),不同进程间的资源默认是隔离的(需要通过进程间通信共享)

我们举个日常使用软件的例子来说明上述三句话就是:当我们开启一个QQ的时候,就是开启了一个进程,此时该进程会申请地址空间等资源,接着我们会聊天、传文件等,这些内部的任务会使用申请的资源以线程的形式运行(调度CPU执行)。然后我们打开浏览器,这个时候就又开启了一个进程接着做同样的任务调度!需要注意的是,进程也是有内核调度的,即什么时候执行浏览器,什么时候执行QQ进程,但是我们这里说的是,线程是内核调度的“最小单位”,也就是当进程间发生调用的时候实际反应到的还是线程!同时有一种解释是,线程模型只不过是把进程模型中“资源管理”和“资源调度”抽离出来,把原先负责“资源管理”的交给“进程”来负责,而重新定义一种新的概念“线程”来专门负责“资源调度”,从CPU角度来看,进程和线程只是CPU不同执行时期的一种描述罢了!

需要注意的是,正如前言叙述中特意加上双引号的“同时”,进程和线程的执行在并发执行的时候采用的是抢占式调度(与之对应的是协同式调度),即CPU时间片轮转的方式来执行相应的程序,使得开起来好像是同时的一样,但是在多核处理器中,有并行执行的概念,这个时候程序时真正的同时执行!

问题二、为什么需要线程?

之所以采用线程,主要有以下三个方面的考量:
1、有时候我们的确需要一些地址空间的共享,然而这是进程不能达到的或者会发生较高的代价
2、进程间的创建和切换是巨大的,在许多操作系统中线程的速度几乎是进程间的10~100倍;如下是进程和线程中需要处理的内容,由此我们可以看到使用一个进程的复杂程度(引自《现代操作系统》):
进程:
···地址空间
···全局变量
···打开文件
···子进程
···即将发生的报警
···信号与信号处理程序
···账户信息
线程
···程序计数器
···寄存器
···堆栈
···状态
3、对于CPU密集型的任务,多线程并不能带来性能的改观,但是如果存在大量的IO操作,那么因为IO的缓慢速率而阻塞该进程下的其他无关任务的执行,将会直接反映到进程的缓慢效率,所以通过多线程的操作避免了IO操作带来低效率;
4、多线程的实现使得多CPU的并行实现有了可能!(这一点不在该文讨论内容)

问题三、从系统层面来看,线程实现的机制是什么?

目前从操作系统层面看,几乎所有的现代操作系统都支持进程和线程!线程的实现方式主要有三种:
1、内核线程:
CPU本身会根据内核线程调度器创建和调度不同的内核线程,但是上层不会直接调用这些内核线程,而是会使用内核线程的高级接口——轻量级进程(LWT)来完成线程的使用。这种方式的映射比利是1:1如图所示:
这里写图片描述
这种方式的优点很突出:
·····1、线程本身的调度完全由系统层面来完成,降低了上层程序的复杂度,减少了不必要的问题
·····2、由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作;
但是缺点也有:
·····1、每次轻量级进程发起的操作,如创建、析构、同步等,都需要进行系统调用,而系统调用的代价很高,需要在用户态和内核态进行切换
·····2、每个轻量级进程都需要内核的支持,但是内核的资源是有限的,这也就决定了轻量级进程的数量是有限的
2、用户线程
广义上讲,非内核线程就是用户线程,所以轻量级进程一定程度也可以称为用户线程,但是由于它完全依靠内核(只是内核线程的一种接口调用),所以我们把它归类为内核线程。这里的用户线程是完全建立在用户空间上的,CPU并不知道用户线程的存在,进程与线程的比例关系是1:N,如图:
这里写图片描述
所以线程的建立、同步、销毁、调度的机制都将在用户态中完成。所以有优点:
···1、线程切换资源消耗小,速度快!
···2、可支持大规模的线程
同样有缺点:
····在用户态自主实现线程的调度机制是很困难的!
所以,现在一个事实是,Java、Ruby原来都曾实现过用户态线程,但是后来都放弃了!
3、混合线程
顾名思义,就是混合内核线程和用户线程的方式!如图:
这里写图片描述
实现的方式很容易看明白,内核线程首先通过高级接口映射成轻量级进程,然后由轻量级进程再映射成多个用户线程,这种映射的比例的关系是N:M,即不固定的!许多UNIX操作系统,如Salaris等都是采用这种系统

那么Java的多线程采用的是哪个呢?
JDK1.2的时候,是基于“绿色线程”的用户线程实现的,到了JDK1.2中则是基于操作系统的线程模型设计的,即操作系统采用什么方式,Java就采用什么方式!如对于SunJDK来说,windows和Linux都是采用1:1的线程模型!

注:这里讲一下用户态和内核态:

当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。这与处于内核态的进程的状态有些类似。
详细阐述参考:内核态和用户态的区别
问题四、多线程的内存模型?


JAVA多线程的内存模型是参考系统的并发架构来设计的,由于主内存在IO操作和CPU打交道的时候远远跟不上CPU的速度,这就使得CPU大部分的时候都在等待主内存的执行完成,所以计算机在设计的时候在他们之间添加了“高速缓存”设备:将运算需要使用的数据复制到这个缓存中,让CPU运算能快速运行,当计算完成后再同步到主内存中;如图所示:
这里写图片描述
我们看到在高速缓存和主内存中存在被称作“缓存一致性”的协议,这是因为当多处理器在并发处理同一内存的同一数据的时候,将会产生缓存数据冲突的问题,如果真的发生这种情况将不知道哪个并发操作的值是我们所需要的,所以必须遵循缓存一致性以消除这种歧义。与此类似,JAVA的多线程的内存模型如图所示:
这里写图片描述
各个类别可以参照上述图例对应理解,JAVA线程要求所有的数据必须拷贝到自己的工作内存中进行运算,运算完成后再同步到主内存中,各个工作内存的数据是私有的不可共享,其中Save和Load等操作被称作原子性操作,就是为了尽力线程的安全,其实JAVA多线程模型中共规定了8种原子性操作,所谓原子性操作就是该操作在同一个线程中要么执行完成并同步到主内存要么不执行。

问题五、JAVA的多线程模型带来什么安全问题?

线程安全是一个狭义的概念,特指不同线程操作同一数据的时候不会带来歧义性,比如有一个数据a = 1,我们采用两个线程对它进行加1(a++)操作,在一个执行周期中(两个线程都恰好执行完一次),我们期待的结果是a = 3,但是由于“不安全”性,当线程A把数据a拷贝到工作内存中时,执行加1操作后,然后切换到了B线程,同样从主内存中执行同样的操作,此时A线程执行同步到主内存得到a=2,然后B线程执行同样的操作,a = 2覆盖了了线程A的结果,所以最终a = 2;也许你会问,不是有8中原子性操作保证了线程安全吗?注意,原子性操作是JAVA字节码指令层级的安全,对于上层的代码来说我们期望a++是原子性的操作(即a++的结果必须同步到主内存然后B线程才能执行),但是编译成字节码来看就不是如此的了:
源码:

public class Yuanzi {
  static int a = 0;

  public static void main(String[] args) {
    a ++;
  }
}

字节码:

public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field a:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field a:I
       8: return

  static {};
    Code:
       0: iconst_0
       1: putstatic     #2                  // Field a:I
       4: return
}

在字节码层面,我们看到一个a++被分为了四步操作,显然就不是原子性操作了!所以字节码层面的原子性操作并不能保证多线程的安全性!

问题六、如何保证多线程的安全性?

由上述所述,我们不禁要问:Java是如何保证多线程的安全性呢?我们把保证线程安全称作线程同步。这里主要有以下几种处理方法:
1、特殊变量关键字:volatile!
volatile是一个轻量级的同步机制,凡是用它修饰到的变量,当被线程操作时会保证以下两种特性:
***立即可见性:***即对变量a的操作将会立即同步到主内存并对其他线程立即可见
***禁止指令重排性:***指令重排是指对于同一个运算可以重新排列执行次序然后把不同次序的结果在进行组合得到最终的结果,一个典型的例子即使加减乘除运算:例如1+4+5=10,我们可以按照顺序计算,当然也可以先计算4+5,然后再计算1+,这样做的好处就是,在硬件层面看来,同一个指令可以不按照顺序拆分为不同的部分发送给相应的计算电路从而提高执行的效率。volatile则为了保证有些指令不该被提前执行时就必须按照程序的逻辑来执行。
2、原子化类:
我们看到volatile只保证了立即可见性和禁止指令重排性,但是另一个重要的安全机制:原子性并不能保证,这也就意味着即使a被volatile修饰,那么a++等非原子性操作的运算在多线程中也是不安全的。原子化类是java atomic包中提供的机制,当一个变量被声明为原子变量时,那么对它的操作将是原子性的;如下:
AtomicInteger atomicInteger = new AtomicInteger(1);
3、互斥锁:
互斥锁是最常见的同步代码机制,在java中用关键字synchronized来修饰,有同步代码块和同步方法两种应用方式,如果修饰的是代码块则必须显性的给出同步锁(一般使用所在类的字节码对象),如果修饰的是静态方法则是所在类的字节码对象,如果是普通方法则是this。由于同步锁被称为“重量级锁”,所以一般使用它时尽量在必须使用的时候才使用,否则将带来性能上的降低,这也是为什么许多线程安全的集合类被抛弃了的原因。另一个互斥锁是ReentranLock,由于它是一个类所以有更多的属性用来设置,如使用公平锁还是非公平锁(synchronized是非公平锁)、等待可中断等
4、线程本地存储:
这里特指ThreadLock类,该类会为每个线程创建一个私有变量副本,这样就可以在各个线程中安全的操作该副本而不会影响到其他线程的变量!注意:ThreadLock的目的本省并不是保证“同步”,它只是提供了一种对全局变量安全操作的一种机制
强烈注意:线程的安全性将是接下来重点讲述的内容,如果需要更加深入的理解请参看该专栏其他文章!下面是该主题下专栏内容的计划列表(实际内容请以实际列表为准)
1、Volitile实现原理
····1、什么是可见性、原子性、指令重排性?
····2、为什么volatile可见性不保证线程安全?
2、synchronzed实现原理
····1、synchronized的实现原理是什么?
····2、为什么线程切换效率较低?
3、Auto原子类实现原理
····1、Auto原子实现的原理是什么?
4、ReentranLock实现原理
····1、ReentranLock在使用上和synchronized有什么区别?
····2、实现原理是什么?
5、ThreadLock实现原理
····1、实现原理?
····2、有哪些应用场景?

最近手残,搞了个公众号,主要闲暇时间随便聊一些程序圈的一些事,也会分享一些技术面试的资料,感兴趣的可以关注一波。关注后,后台发送 面试指南,可以获取2021最新JAVA面试总结,基本看完后,JAVA八股文这些应该不在话下了。

在这里插入图片描述

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值