JVM—Java线程与锁

JVM—Java线程与锁

1.线程的实现

线程是比进程更轻量级的调度执行单位, 线程的引入, 可以把一个进程的资源调度和执行调度分开, 各个线程既可以共享进程资源(内存地址文件I/O等), 又可以独立调度(线程是CPU调度的独立单位)

线层的实现方式主要有三种 : 使用内核线程实现, 使用用户线程实现和使用用户线程加轻量级进程混合实现

1> 使用内核线程实现

内核线程就是直接由操作系统内核支持的线程, 这种线程由内核来完成线程切换, 内核通过操纵调度器, 对线程进行调度, 并负责将现成的任务映射到各个处理器上.

程序一般不会直接去使用内核线程, 而是去使用内核线程的一种高级接口–轻量级线程,就是通常意义上的线程. 其与内核线程有一对一的关系

由于内核线程的支持, 每个轻量级进程都成为一个独立的调度单元, 即使有一个轻量级进程在系统调用中阻塞了, 也不会影响整个进程继续工作. 但是轻量级进程也具有局限性

  • 由于是基于内核线程实现, 所以各种线程操作 ,如创建, 析构和同步都需要进行系统调用. 而系统调用需要在用户态和内核态之间进行切换, 代价较高
  • 轻量级进程要消耗一定的内核资源, 因此一个系统支持轻量级进程的数目是有限的
2>使用用户线程实现

狭义上用户线程是指完全建立在用户空间的线程库上, 系统内核不能感知线程存在的实现.用户线程的建立, 同步, 销毁和 调度完全在用户态中完成, 不需要内核的帮助. 如果程序实现得当, 这种线程不需要切换到内核态, 因此操作可以是非常快且低消耗的, 可支持更大规模的线程数量, 进程与用户线程之间的关系是一对多的.

但其劣势就是所有线程操作都需要用户程序自己处理, 很多问题将是无法处理的, 现在使用用户线程的程序越来越少了

3>使用用户线程加轻量级进程混合实现

在这种混合实现下,既存在用户线程, 又存在轻量级进程. 这样既可以做到低消耗的创建,切换,析构用户线程. 又可以通过轻量级进程使用内核进程的线程调度功能和处理器映射

4>Java线程的实现

在目前的JDK版本中, 操作系统支持怎样的线程模型, 在很大程度上决定了Java虚拟机的线程是如何实现的.

对于Sun JDK来说,Windows和Linux都是使用一对一的线程模型实现 的, 一条Java线程就映射到一个轻量级进程之中, 因为Windows和Linux提供的线程模型就是一对一的.

2.Java线程调度

线程调度是指系统为线程分配处理器使用权的过程, 主要调度方法有两种, 分别是协同式调度和抢占式调度.

  • 协同式调度:线程的执行时间由线程本身来控制,线程把自己的工作处理完之后, 要主动通知系统切换到另一个线程上. 好处:没有线程同步的问题. 坏处 : 线程的执行时间不可控制
  • 抢占式调度:每个系统由系统来分配执行时间,线程的切换不由线程本身来决定.(在Java中,Thhread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没什么办法的). Java使用的线程调度方式就是抢占式调度

Java语言一共设置了10个级别的线程优先级,在两个线程处于Ready状态,优先级越高的线程越容易被系统选择执行.但是线程优先级并不是太靠谱, 原因是Java的线程是通过映射到系统的原生线程上来实现的, 所以线程调度最终还是取决于操作系统.

3.状态转换

Java语言定义了5种线程状态, 在任意一个时间点, 一个线程有且只能有其中的一种状态

  • 新建(New) : 创建后尚未启动的线程处于这种状态

  • 运行(Runable) : Runable包括了操作系统线程状态中的Running和Ready, 也就是处于此状态的线程有可能正在执行, 也有可能正在等待CPU为它分配时间

  • 无限期等待(Waiting) : 处于这种状态的线程不会被分配CPU时间, 它要等待被其他线程显式地唤醒, 以下方法会让线程陷入无限期的等待状态

    • 没有设置Timeout参数的Object.wait()方法
    • 没有设置Timeout参数的Thread.join()方法
    • LockSupport.park()方法
  • 限期等待(Timed Waiting) : 处于这种状态的线程也不会被分配CPU执行时间, 不过无须等待其被其他线程显式地唤醒, 在一定时间之后它们会由系统自动唤醒 , 一下方法会让线程进入限期等待状态 :

    • Thread.sleep()方法
    • 设置了Timeout参数的Object.wait()方法
    • 设置了Timeout参数的Thread.join()方法
    • LockSupport.parkNanos()方法
    • LockSupport.parkUntil()方法
  • 阻塞(Blocked) : 线程在等待着获取一个排它锁 在程序等待进入同步区的时候, 线程将进入这种状态

  • 结束(Terminated) : 已终止线程的结束状态, 线程已经结束执行

4.线程安全

当多个线程访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行, 也不需要额外的同步,或者在调用方进行任何其他协调操作, 调用这个对象的行为都可以获得正确的结果, 那么这个对象就是线程安全的

Java语言中各种操作共享的数据分为以下5类 : 不可变 , 绝对线程安全, 相对线程安全, 线程兼容和线程对立

  • (1).不可变 : Java语言中, 不可变(Immutable)的对象一定是线程安全的. 在java中, 如果共享数据是一个基本数据类型, 那么只需要在定义时使用final关键字修饰他就可以保证它是不可变的. 如果共享数据是一个对象, 那就需要保证对象的行为不会对其状态产生任何影响, 如String类,枚举类,Long和Double等数值包装类型,还有java.lang.Number的部分子类,BigInteger等大数类
  • (2).绝对线程安全 : 绝对线程安全是很严格的,java中大部分类都不是绝对线程安全的.例如, java.util.vector是线程安全的容器, 因为它的add(),get()和size()这类方法都是被synchronized修饰的,尽管这样效率很低, 但确实是安全的. 不过即使它所有的方法都被synchronized修饰, 也不意味着它调用的时候不需要同步手段. 因为如果一个线程在错误的时间里删除了一个元素, 导致序号i已经不再可用, 再用i访问数组就会抛出数组越界异常., 所以在调用时依然要使用同步手段
  • (3).相对线程安全 : 相对线程安全就是我们通常意义上的线程安全, 它需要保证对这个对象单独的操作是线程安全的, 在调用时不需要额外的保障措施, 但对于一些特定顺序的连续调用, 就可能需要在调用端使用额外的同步手段来保证调用的正确性. 在Java语言中, 大部分线程安全类都属于这种类型, 例如Vecor,HashTable,Collections的synchronizedCollection()方法包装等
  • (4).线程兼容 : 是指对象本身并不是线程安全的, 但是可以通过在调用端正确的使用同步手段来保证对象在并发环境下可以安全地使用.
  • (5).线程对立 : 是指无论调用端是否采用了同步措施, 都无法在多线程环境中并发使用的代码. 例如Thread类的suspend()和resume()方法, 如果两个线程同时持有一个线程对象, 一个尝试去中断线程, 一个尝试去回复线程, 如果并发进行的话, 无论调用时是否进行了同步, 目标线程都是有可能存在死锁风险的. 这也就是这两个方法已经被JDK废弃的原因.

5>线程安全的实现方法

1>互斥同步

同步是指多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或者是一些,使用信号量的时候)线程使用. 而互斥是实现同步的一种手段, 临界区, 互斥量和信号量都是主要的互斥实现方式.

在java中, 最基本的同步手段就是synchronized关键字, synchronized关键字经过编译之后, 会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令, 这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象. 如果没有明确指定, 那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象作为所对象.

在执行monitorenter时,首先要尝试获取对象的锁. 如果没被锁定, 或当前线程已经拥有了那个对象的锁,把锁的计数器加1.相对应的在执行monitorexit时把锁计数器减1,当计数器为0时,锁就被释放. 如果获取对象锁失败, 那当前线程就要阻塞等待, 直到对象锁被另外一个线程释放为止

因为synchronized是可重入的, 所以不会存在自己把自己锁死的问题. 其次同步块在已进入线程执行完之前, 会阻塞后面线程的进入. 如果要阻塞一个线程, 需要操作系统从用户态转到核心态中, 因此状态转换需要耗费很多处理器时间. 所以synchronized是一个重量级的操作.

除了synchronized, 我们还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步, 使用lock()和unlock()方法来完成同步, 不过加入了一些synchronized没有的高级功能 : 等待可中断, 可实现公平锁,锁可以绑定多个条件

  • 等待可中断 : 当持有锁的线程长期不释放锁的时候, 正在等待的线程可以选择放弃等待, 改为处理其他事情
  • 公平锁 : 必须按照申请锁的顺序依次获得锁
  • 锁绑定多个条件 : 一个ReentrantLock对象可以同时绑定多个Condition对象.
2>非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和 线程唤醒所带来的性能问题, 所以也叫作阻塞同步. 互斥同步属于一种悲观的并发策略, 也就是说无论共享数据是否真的会出现竞争, 都要进行加锁(实际上虚拟机会优化掉很多不必要的加锁).

随着硬件指令集的发展, 我们有了另一个选择 : 基于冲突检测的乐观并发策略, 也就是说 : 先进行操作, 如果没有其他线程争用共享数据, 那操作就成功了, 如果共享数据有争用, 产生了冲突, 那就再采取其他的补偿措施(常见的是,不断地重试直到成功), 这种乐观的并发策略不需要把线程挂起, 所以叫非阻塞同步

因为我们需要操作和冲突检测两个步骤具备原子性, 其需要在硬件上实现. 硬件保证一个从语义上看需要多次操作的行为只通过一条处理器指令就能完成, 这类指令有 :

  • 测试并设置(Test-and-set)
  • 获取并增加(Fetch-and-increment)
  • 交换(swap)
  • 比较并交换(compare-and-swap)
  • 加载链接/条件存储(LL/SC)

CAS指令需要有三个操作数: 内存位置V, 旧的预期值A,和新值B. CAS指令执行时, 当且仅当V符合旧预期值A时,处理器用B更新V的值, 否则就不更新. 但无论是否更新, 都会范围V的旧值,这样的操作是一个原子操作

J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了CAS操作

CAS看起来很完美, 但是这种操作无法涵盖互斥同步的所有使用场景, 如果一个变量V初次被读时是A值, 并在准备赋值的时候仍然是A值,那我们就能说它没有被改变过吗 ? 如果在这段期间它的值被改成了B, 后来又改为A,那么CAS操作就会误以为它没有被改变过. 这个漏洞称为CAS的"ABA"问题

3>无同步方案

同步只是保证共享数据争用时的正确性的手段, 如果一个方法本来就不涉及共享数据,那它就无须任何同步手段去保证正确性,因为有一些代码天生就是线程安全的,

  • 可重入代码 : 这种代码也叫纯代码, 可以再代码执行的任何时刻中断它, 转而去执行另外一段代码, 在控制权返回后, 原来的程序不会出现任何错误. 可重入代码有一些共同特征, 例如不依赖存储在堆上的数据和公共的系统资源, 用到的代码都有参数中传入, 不调用非可重入的方法等.
  • 线程本地存储 : 如果一段代码中所需要的数据必须与其他代码共享, 如果能保证这些共享数据的代码在同一个线程中执行,就可以把共享数据的可见范围限制在同一个线程之内. 例如消费队列的架构模式(“生产者-消费者"模式). 在java中, 如果一个变量要被多线程访问, 可以使用volatile关键字声明它为"易变的”; 如果一个变量要被某个线程独享, 可以通过java.lang.ThreadLocal类来实现线程本地存储的功能. 每一个线程的Thread对象中都有一个ThreadLocalMap对象, 这个 对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对, ThreadLocal就是当前线程的ThreadLocalMap的入口, 每个ThreadLocal都包含独一无二的threadLocalHashCode值,使用这个值可以在线程K-V值对中找回本地线程变量
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值