多线程总结

引言

经过了这么多天的线程学习,终于告一段落了。线程这里面涉及的问题有很多,知识面也很广,其中最重要的就是线程安全问题,这是多线程编程的基础。想要学好多线程光靠短段时间的学习肯定是远远不够的,还需要在日后不断的去积累、去深入。

什么是线程

线程是CPU最小执行单元,一个进程可以包括多个线程,多个线程之间共享同一份资源;一个进程中最少要包含一个线程,而这时这一个线程也可以叫做进程,所以线程也可以被叫做"轻量级进程";线程有如下几个状态:

  1. 新建状态(NEW):当使用new关键字创建了一个线程时,该线程就属于新建状态,这时JVM会为该线程分配内存空间并初始化它里面的成员变量;
  2. 就绪状态(RUNNABLE):当线程调用了start()方法后,其会进入就绪状态,这时JVM会为其创建方法调用栈和程序计数器,等待其运行
  3. 运行状态(RUNNING):当线程获得了CPU资源,开始执行run()方法时,其就会进去运行状态;
  4. 阻塞状态(BLOCKED):线程进入该状态就说明暂时停止了运行,有三种情况会进入阻塞状态,第一种是当线程调用了Object类的wait()方法,第二种是当线程锁竞争失败时,第三种是主线程调用sleep()或者join()函数时
  5. 死亡状态(DEAD):当线程执行完run()或者call()方法时线程就会自然结束,但是也有可能意外终止,如抛出异常;还可以调用stop()函数来终止进程,但是这种方法容易造成死锁,不推荐使用。

线程和进程的区别

  1. 进程是系统分配资源的最小单位,线程是系统执行的最小单位
  2. 进程之间是互相独立,而线程之间会共享资源,只独享指令流执行的资源,如寄存器和栈等
  3. 创建进程要消耗的资源要高于创建线程,切换和终止的效率要低于线程
  4. 进程之间通信需要内核来完成,而线程间通信不需要借助内核

创建线程的几种方式

  1. 创建一个类继承Thread类,在其中重写run()方法
  2. 创建一个类实现Runnable接口,在其中重写run()方法,然后把这个类的实例对象当参数传个Thread类
  3. 创建一个类实现Callable接口,在其中重写call()方法,然后使用FutureTask类包裹这个类的实例对象,然后把FutureTask类的实例对象当参数传个Thread类
  4. 使用lambda 表达式创建 Runnable 子类对象,传给Thread对象

Thread类中常用的方法

  1. start()方法:用来启动线程,调用这个方法首先会判断threadStatus等不等于0,如果不等于0说明前面已经调用过一次start()方法了,这时就会抛出异常,如果等于这个值就会加1,然后会去调用一个start0()的本地方法,在这个方法中就会调用run()方法。
  2. join()方法:使用这个方法会让主线程进入阻塞状态,让主线程等待调用这个函数的线程,直到它执行完毕,才会再去进入运行状态。(谁调用join()方法就会让其他线程等待)
  3. interrupt():调用这个方法会让线程捕捉到异常,从而通过catch中的break跳出循环

sleep()和wait()的对比

理论上sleep和wait是没有可比性的,一个是用于线程之间通信的,一个是让线程阻塞一段时间,唯一相同点就是都会让线程放弃一段执行时间而已,这里我们只是相当于总结一下,没什么对比的意思

  1. sleep是Thread类中的方法,wait是Object类中的方法
  2. sleep使用不依赖锁,也就是说没有锁也能用,上锁的话不会释放锁;而使用wait必须要先上锁,然后执行wait函数时会释放锁
  3. 使用sleep方法会让程序停止执行指定的时间,但是它的监控状态依然保持着,当指定时间过后会重新进入运行状态,而调用普通的wait方法之后是需要notify方法去唤醒的

发生线程安全问题的原因

  1. 线程之间是抢占式执行的方式,因为线程之间是并发执行的,并没有一定的顺序,每个线程都有一个时间片,也就是程序执行时间,这段时间可能程序还没有执行完,就有第二个线程执行了
  2. 多个线程修改同一个变量
  3. 操作的原子性,计算机执行代码是编译成一条条机器指令去执行的,这些指令并不是一口气会执行完的,而是会一次执行几条指令,下次再执行另外几条指令
  4. 内存可见性,说内存可见性就要提到JMM模型的概念了,在JMM中有工作内存和主内存之分,工作内存是每个线程独享的,而主内存是所有线程共享的,线程要执行代码,就需要先从主内存拿数据放到工作内存中,然后从工作内存再读取数据,因为从主内存直接读取数据对计算机来说是一个很慢的过程,所以就可能造成一种情况,一个线程修改完数据没有及时放入主内存中,导致另外一个线程看到的还是没有改变过的值
  5. 指令重排序,我们的代码在经过编译器和JVM时会被自动优化,也就是可能代码的执行顺序会发生改变,但是无论怎么改变都会遵顼一个as-if-serial的规则,意思是说保证在单线程下代码的正确性,但是不能保证多线程情况下的,所以就会出现问题

解决线程安全问题

  1. 对于读多写少的情况,可以使用volatile关键字,这是一个可以保证内存可见性和防止指令重排序的关键字,但是不能保证复杂操作的原子性,但是它的使用比较轻量,所以使用好了也是非常好用的
  2. 大多数情况可以使用synchronized关键字,这个关键字在jdk1.8之前是比较笨重的,使用代价很大,但是在1.8之后,开发人员对它做出了一些优化,让它成为了一个自适应性的锁,在开始时处于一个无锁状态,在之后会升级为偏向锁,有了线程竞争后会再升级为轻量级锁,最后竞争激烈的话会升级为重量级锁。

synchronized实现原理

  • JVM会为每一个Java对象维护一个Monitor对象,获取锁其实就是在获取Monitor对象,当一个线程获取到之后,其他线程就不能在获取到了,只有当它释放锁之后,其他线程才能获取到这个Monitor对象,就是由于这样的机制来实现多线程的安全性的
  • synchronized 关键字是依赖 MonitorEnter 和 MonitorExit 指令来实现的:在 JVM 编译代码的时候,会在 synchronized 代码块的入口处插入 MonitorEnter 指令,在 synchronized 代码块的出口(正常结束或是异常退出的地方)插入 MonitorExit 指令。MonitorEnter 指令就是用来锁定指定对象的 Monitor,MonitorExit 指令则是用来释放指定对象的 Monitor,从而完成了对对象的加锁和解锁效果,实现了操作的原子性
  • 在获取 Monitor 的时候,线程的工作内存会被设置为无效,并从主内存中重新加载数据;在释放 Monitor 的时候,线程工作内存中的数据会同步到主内存,这样也就保证了可见性。另外,MonitorEnter、MonitorExit 指令也可以防止重排序

volatile实现原理

  1. 首先在写入volatile变量时,JVM会向CPU发送一个lock指令,这个指令有两个效果,一个是让当前工作内存的数据写回到主内存中,另一个是当写回操作涉及的数据会引起其他CUP核中缓存了该地址的数据无效,这也就保证了当下一个线程读的时候一定可以读到最新更新的值
  2. 在JVM编译代码时,会在涉及到volatile读操作的后面插入插入两个内存屏障,在volatile写操作的前后各加入一个内存屏障,内存屏障是硬件层面的指令,它可以禁止屏障前后的命令进行重排序

多线程编程的应用

  1. 阻塞队列,也就是在说生产者消费者模型,利用一个中间队列,来平衡生产和消费双方的一个平衡,当队列满的时候就不能再插入元素了,当队列为空的时候同样也不能取出元素,这里面主要是用到了wait()和notify()两个方法,当队列满的时候会调用wait方法,然后等待出去元素时执行notify方法,反过来的过程是一样的
  2. 单例模式是说一个类只能有一个实例,这里面是通过synchronized实现的,首先让构造方法私有化,这样就不能通过new方法来实例对象了,然后再构造一个静态方法,这样可以通过类名直接调用方法,然后通过synchronized保证只能实例化一个对象
  3. 定时器是通过使用带优先级的阻塞队列来实现的,首先创建一个Task类,里面存放的是需要执行的任务和时间间隔,然后Timer类的构造方法中加入一个线程,每次到时间就会去执行阻塞队列中最上面的一个任务
  4. 简单的线程池,通过阻塞队列储存任务,然后使用一个数组保存线程,让线程使用完之后不销毁,而是一直存在,使用的时候直接加入任务就可以了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值