《菜鸟读并发》并发编程必知必会概念

什么是进程?

  • 系统运行程序的基本单位
  • 程序的一次执行过程,系统运行一个程序即是一个进程从创建,运行到消亡的过程,所以进程是动态的
  • 任务管理器中window 当前运行的进程都是以.exe 为后缀的

在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程

什么是线程?

  • 线程是一个比进程更小的执行单位(线程也被称为轻量级进程,在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多)
  • 一个进程在其执行的过程中可以产生多个线程
  • 同类的多个线程共享进程的堆和方法区资源
  • 每个线程有自己的程序计数器、虚拟机栈和本地方法栈
  • 线程是操作系统里的一个概念,虽然各种不同的开发语言如Java、C#等都对其进行了封装,但是万变不离操作系统。Java 语言里的线程本质上就是操作系统的线程,它们是一一对应的
Java 程序天生就是多线程程序,我们可以通过 JMX 来看一下一个普通的 Java 程序有哪些线程,代码如下:

public class MultiThread {
	public static void main(String[] args) {
		// 获取 Java 线程管理 MXBean
	ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
		// 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
		ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
		// 遍历线程信息,仅打印线程 ID 和线程名称信息
		for (ThreadInfo threadInfo : threadInfos) {
			System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
		}
	}
}

上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):

[5] Attach Listener //添加事件
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
[3] Finalizer //调用对象 finalize 方法的线程
[2] Reference Handler //清除 reference 线程
[1] main //main 线程,程序入口

从上面的输出内容可以看出:一个 Java 程序的运行是 main 线程和多个其他线程同时运行。

图解进程和线程的关系

从 JVM 角度说进程和线程之间的关系
下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。如果你对 Java 内存区域 (运行时数据区) 这部分知识不太了解的话可以阅读一下这篇文章:《可能是把 Java 内存区域讲的最清楚的一篇文章》[1]

从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。

总结:
  • 一个进程在其执行的过程中可以产生多个线程
  • 线程是进程划分成的更小的运行单位。(线程也被称为轻量级进程,在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多)
  • 同类的多个线程共享进程的堆和方法区资源
  • 每个线程有自己的程序计数器、虚拟机栈和本地方法栈
  • 线程执行开销小,但不利于资源的管理和保护;而进程正相反
  • 线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。

思考:为什么程序计数器、虚拟机栈和本地方法栈是线程私有的呢?为什么堆和方法区是线程共享的呢?

程序计数器

程序计数器主要有下面两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。(保存现场和恢复现场,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置)

注意:

  • 如果执行的是 native方法,那么程序计数器记录的是undefined地址
  • 只有执行的是Java代码时程序计数器记录的才是下一条指令的地址
虚拟机栈
  • 每个Java方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息
  • 从方法调用直至执行完成的过程,就对应着一个栈帧在Java 虚拟机栈中入栈和出栈的过程

本地方法栈

  • 本地方法栈则为虚拟机使用到的 Native 方法服务
  • 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java方法(也就是字节码)服务
  • 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

总结:为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

堆和方法区
  • 堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存.
  • 主要用于存放新创建的对象(所有对象都在这里分配内存)
  • 方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

并发与并行的区别?

  • 并发:同一时间段,多个任务都在执行(单位时间内不一定同时执行);
  • 并行:单位时间内,多个任务同时执行。

为什么要使用多线程呢?

先从总体上来说:

  • 从计算机底层来说:
  1. 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。
  2. 多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
  • 从当代互联网发展趋势来说:
  1. 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础
  2. 利用好多线程机制可以大大提高系统整体的并发能力以及性能。

再深入到计算机底层来探讨:

  • 单核时代:
    在单核时代多线程主要是为了提高CPU和IO设备的综合利用率。

举个例子:当只有一个线程的时候会导致CPU计算时,IO 设备空闲;进行IO操作时,CPU空闲。我们可以简单地说这两者的利用率目前都是50%左右。但是当有两个线程的时候就不一样了,当一个线程执行CPU计算时,另外一个线程可以进行IO操作,这样两个的利用率就可以在理想情况下达到 100%了。

  • 多核时代: 多核时代多线程主要是为了提高CPU利用

举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU只会一个CPU核心被利用到,而创建多个线程就可以让多个CPU核心被利用到,这样就提高了 CPU 的利用率。

并发编程的目的是什么?

  • 为了能提高程序的执行效率提高程序运行速度

并发编程可能会遇到什么问题?

  • 内存泄漏
  • 上下文切换
  • 死锁
  • 受限于硬件和软件的资源闲置问题

什么是上下文切换?

  1. 多线程编程中一般线程的个数都大于CPU核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用
  2. 为了让这些线程都能得到有效执行,CPU采取的策略是为每个线程分配时间片并轮转的形式。
  3. 当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换
  4. 任务从保存到再加载的过程就是一次上下文切换,当前任务在执行完 CPU时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。
  5. 多线程不一定比单线程快的原因可能是线程创建和上下文切换的原因导致的

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的CPU时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

减少上下文切换的方法:
  1. 无锁并发编程,可以将数据id进行hash算法取模分段,不同线程处理不同的数据(分段锁)
  2. cas算法,java中Atomic包使用的cas算法更新数据
  3. 使用最少线程,合理设置线程数量,避免大量的线程处于等待
  4. 使用协成,单线程实现任务调度,并且在单线程维护多个任务的切换

性能分析工具Lmbench3可以测量上下文切换的时间,vmstat可以测量上下文切换的次数(cs表示),具体分析看我的另外一篇博客文章

资源限制并发的挑战:

(受限资源,并发执行的任务任然串行执行,这时候反而增加了上下文切换的时间和调度时间)

  1. 硬件资源:带宽的上传下载速度,硬盘的读写速度,cpu的处理速度
  2. 软件资源:数据库连接数,socket的数量

死锁的形成:

死锁一般发生在线程在完成一个件事的时候,需要申请多把锁,出现线程之间相互持有对方所需的锁,此时容易发生死锁(通过dump可以查看死锁)

举个例子
import java.util.List;
public class LockTest {
    
    // 例如这个方法上有一个事务注解
    public void updateBusiness(List<Long> goodsIds) {

        if(goodsIds == null || goodsIds.isEmpty()) {
            return;
        }
        IGoodsDao goodsDao = null;

        for(Long gid : goodsIds) {
            goodsDao.updateGoods(gid, 1);  // 将库存减去1,需要持有该记录的行锁
        }
    }
}

interface  IGoodsDao {
    // 减库存
    void updateGoods(Long goodsId, int nums);
}
  1. 如果一个用户要购买商品ID 为 1,3 的商品
  2. 而另外一个用户需要购买同样的商品,但是在购物车中选择商品的顺序是 3,1
  3. 此时两个线程同时调用 updateBusiness 方法

执行轨迹如下:

这样就出现了死锁。

避免死锁的方法:
  1. 数据库锁的加锁和解锁需要在同一个数据库连接里,否则会出现解锁失败
  2. 避免一个线程获取多个锁
  3. 尝试使用定时锁,lock.tryLock(timeOut)替换内部锁
  4. 避免一个锁内同时占用多个资源,尽量保证每一个锁内只占用一个资源

针对上面的例子,通常的解决办法是,先对锁申请进行排序。

// 例如这个方法上有一个事务注解
    public void updateBusiness(List<Long> goodsIds) {

        if(goodsIds == null || goodsIds.isEmpty()) {
            return;
        }
        IGoodsDao goodsDao = null;

        Collections.sort(goodsIds);

        for(Long gid : goodsIds) {
            goodsDao.updateGoods(gid, 1);  // 将库存减去1,需要持有该记录的行锁
        }
    }

这样就不会出现死锁了。

提高并发的两种方式

  • 无锁并发编程
  • 分段锁(数据的id进行hash取模分段,不同的线程处理不同段的数据)

并发机制的底层实现原理
java代码在编译之后会变成java字节码,字节码被类加载器加载到jvm里,jvm执行字节码,最终需要转换成汇编指令在CPU上运行,java锁使用的并发机制依赖于jvm的实现和cpu指令

Java 内存模型

Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为, Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile 、 synchronized 和 final 三个关键字,以及六项 happen - Before 规则,后续的文章我们会一一来学习。

如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。 感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

原创不易,欢迎转发,关注公众号“码农进阶之路”,获取更多面试题,源码解读资料!
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值