个人笔记 Java多线程(九)

这段笔记是参照b站教程BV1Rv411y7MU整理而来的,用于个人备忘以便复习,需要的朋友可以自取。

保障线程安全技术

1. Java运行时存储空间

Java运行时(Java RunTime)空间可以分为栈区、堆区和方法区(非堆空间)。

栈空间(Stack Space)为线程的执行准备一段固定大小的存储空间,每个线程都持有独立的线程栈空间,创建线程时就为线程分配栈空间,在线程栈中没调用一个方法就给方法分配一个栈帧,栈帧用于存储方法的局部变量,返回值等私有数据,即局部变量存储在栈空间中,基本变量也存储在栈空间中,引用类型变量值也是存储在栈空间中,引用的对象存储在堆中。
由于线程栈是相互独立的,一个线程不能访问另一个线程的栈空间,因此线程对局部变量以及只能通过当前线程局部变量才能访问的对象进行的操作具有固定的线程安全性。

堆空间(Heap Space)用于存储对象,是在JVM启动是分配的一段可以用于动态扩容的存储空间。创建对象时,在堆空间中给对象分配存储空间,实例变量就是存储在堆空间中的,堆空间是多个线程之间可以共享的空间。因此实例变量可以被多个线程共享。
因此多个线程同时操作实例变量就可能产生线程安全问题。

非堆空间(Non-Heap Space)用于储存常量,类的元数据等。非堆空间也是在JVM启动时分配的一段可以动态扩容的存储空间。类的元数据包括静态变量,类有哪些方法以及这些方法的元数据(方法名,参数,返回值等) 。
非堆空间也是多个线程可以共享的存储空间,因此访问非堆空间中的静态变量也可能产生线程安全问题。

堆空间和非堆空间是线程可以共享的空间,即实例变量与静态变量是线程可以共享的,可能存在线程安全问题。栈空间是线程持有的空间,局部变量存储在栈空间中,局部变量持有固有的线程安全性。

2. 无状态对象

对象就是数据及对数据操作的封装,对象所包含的数据称为对象的状态(State),实例变量与静态变量称为状态变量。

如果一个类的同一个实例被多个线程共享,但并不会使得这些线程存储共享的状态(共享对象,但不共享数据),这个类的实例我们称之为无状态对象(Stateless Object)。反之如果一个类的实例被多个线程共享会使这些线程共享状态,那么该类的实例为有状态对象。

实际上无状态对象就是不包含任何实例变量的对象,也不包含任何静态变量

线程安全问题的前提是多个线程存在共享数据,因此实现线程安全的一种办法就是避免在多个线程中使用共享数据,使用无状态对象就是这种办法。

3. 不可变对象

不可变对象是指一经创建它的状态就保持不变的对象,不可变对象具有固有的线程安全性. 当不可变对象现实实体的状态发生变化时,系统会创建一个新的不可变对象,就如String字符串对象. 一个不可变对象需要满足以下条件:

  1. 类本身使用final修饰,防止通过创建子类来改变它的定义。

  2. 所有的字段都是final修饰的,final字段在创建对象时必须显示初始化,不能被修改。

  3. 如果字段引用了其他状态可变的对象(集合,数组),则这些字段必须是private私有的。

不可变对象主要的应用场景:

  1. 被建模对象的状态变化不频繁。

  2. 同时对一组相关数据进行写操作,可以应用不可变对象,既可以保障原子性也可以避免锁的使用。

  3. 使用不可变对象作为安全可靠的Map键, HashMap键值对的存储位置与键的hashCode()有关,如果键的内部状态发生了变化会导致键的哈希码不同,可能会影响键值对的存储位置. 如果HashMap的键是一个不可变对象,则hashCode()方法的返回值恒定,存储位置是固定的。

4. 线程持有对象

对于一个非线程安全对象,每个线程都创建一个该对象的实例,各个线程仅访问各自创建的实例,且一个线程不能访问另外一个线程创建的实例。这种各个线程创建各自的实例,一个实例只能被一个线程访问的对象就被称为线程特有对象,相对应的线程就被称为该线程特有对象的持有线程。

线程特有对象既保障了对非线程安全对象的访问的路径安全,又避免了锁的开销。线程特有对象也具有固有的线程安全性。

ThreadLocal<T>类相当于线程访问其线程特有对象的代理 (Proxy),即各个线程通过这个对象可以创建并访问各自的线程特有对象,其类型参数 T 指定了相应线程特有对象的类型。一个线程可以使用不同的ThreadLocal 实例来创建并访问其不同的线程特有对象。多个线程使用同一个ThreadLocal实例所访问到的对象是类型 T 的不同实例,即这些线程各自的线程特有对象实例。因此, ThreadLocal 类也可以理解为当前线程访问其线程特有对象的代理对象,这种代理与被代理的关系如下图。

ThreadLocal实例为每一个访问它的线程都关联了一个该线程的特有对象,ThreadLocal实例都有当前线程与特有实例之间的关联。

5. 装饰器模式

装饰器模式可以用于实现线程安全。其基本思想是为非线程安全的对象创建一个相应的线程安全的外包装对象,客户端代码不直接访问非线程安全的对象而是访问它的外包装对象。

这个外包装对象与非线程安全对象具有相同的接口,即外包装对象使用方式和非线程安全的使用方式相同,而外包装对象内部通常会使用锁,以线程安全的方式调用相应的非线程安全对象的方法。

在java.util.Collections工具类中提供了一组synchronizedXX(xxx)方法,可以把不是线程安全的xxx集合转换成线程安全的集合,它就是采用了这种装饰器模式,这个方法返回值就是指定集合的外包装对象。这种集合又称同步集合。

使用装饰器模式的一个好处是实现关注点分离,这种设计中,实现同一组功能的对象的两个版本:非线程安全的对象和线程安全的对象。对于非线程安全的对象设计时只关注功能,线程安全的对象设计时只关注线程安全。

锁的优化以及注意事项

有助于提高锁性能的几点建议

  1. 减少锁持有时间

对于使用锁进行并发控制的应用程序来说,如果单个线程特有锁

的时间过长,会导致锁的竞争更加激烈,会影响系统的性能.在程序中

需要尽可能减少线程对锁的持有时间,如下面代码:

  public synchronized void syncMethod(){

      othercode1();

      mutexMethod();

      othercode();

  }

在 syncMethod 同步方法中,假设只有mutexMethod()方法是需要同步的, othercode1()方法与 othercode2()方法不需要进行同步. 如果othercode1 与 othercode2 这两个方法需要花费较长的 CPU 时间,在并发量较大的情况下,这种同步方案会导致等待线程的大量增加. 一个较好的优化方案是,只在必要时进行同步,可以减少锁的持有时间,提高系统的吞吐量,如把上面的代码改为:

  public void syncMethod(){

      othercode1();

      synchronized (this) {

          mutexMethod();

      }

      othercode();

  }

只对 mutexMethod()方法进行同步,这种减少锁持有时间有助于降

低锁冲突的可能性,提升系统的并发能力.

  1. 减小锁的粒度

一个锁保护的共享数据的数量大小称为锁的粒度. 如果一个锁保
护的共享数据的数量大就称该锁的粒度粗,否则称该锁的粒度细.锁的
粒度过粗会导致线程在申请锁时需要进行不必要的等待.减少锁粒度
是一种削弱多线程锁竞争的一种手段,可以提高系统的并发性。

在 JDK7 前,java.util.concurrent.ConcurrentHashMap 类采用分段锁协
议,可以提高程序的并发性.

  1. 使用读写分离锁代替独占锁

使用 ReadWriteLock 读写分离锁可以提高系统性能, 使用读写分离
锁也是减小锁粒度的一种特殊情况. 第二条建议是能分割数据结构
实现减小锁的粒度,那么读写锁是对系统功能点的分割.

在多数情况下都允许多个线程同时读,在写的使用采用独占锁,在
读多写少的情况下,使用读写锁可以大大提高系统的并发能力.

  1. 锁分离

将读写锁的思想进一步延伸就是锁分离.读写锁是根据读写操作
功能上的不同进行了锁分离.根据应用程序功能的特点,也可以对独占
锁进行分离.如 java.util.concurrent.LinkedBlockingQueue 类中 take()与put()方法分别从队头取数据,把数据添加到队尾. 虽然这两个方法都
是对队列进行修改操作,由于操作的主体是链表,take()操作的是链表
的头部,put()操作的是链表的尾部,两者并不冲突. 如果采用独占锁的
话,这两个操作不能同时并发,在该类中就采用锁分离,take()取数据时
有取锁, put()添加数据时有自己的添加锁,这样 take()与 put()相互独立
实现了并发.

  1. 粗锁化

为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽
量短.但是凡事都有一个度,如果对同一个锁不断的进行请求,同步和
释放,也会消耗系统资源.如:

 public void method1(){

     synchronized( lock ){

         同步代码块 1

     }

     synchronized( lock ){

         同步代码块 2

     }

 }

JVM 在遇到一连串不断对同一个锁进行请求和释放操作时,会把所
有的锁整合成对锁的一次请求,从而减少对锁的请求次数,这个操作叫
锁的粗化,如上一段代码会整合为:

 public void method1(){

     synchronized( lock ){

         同步代码块 1

         同步代码块 2

     }

 }

在开发过程中,也应该有意识的在合理的场合进行锁的粗化,尤其在循环体内请求锁时,如:

 for(int i = 0 ; i< 100; i++){

     synchronized(lock){}

 }

这种情况下,意味着每次循环都需要申请锁和释放锁,所以一种更合理的做法就是在循环外请求一次锁,如:

 synchronized( lock ){

     for(int i = 0 ; i< 100; i++){}

 }

JVM对锁的优化

1. 锁偏向

锁偏向是一种针对加锁操作的优化,如果一个线程获得了锁,那么
锁就进入偏向模式, 当这个线程再次请求锁时,无须再做任何同步操
作,这样可以节省有关锁申请的时间,提高了程序的性能.

锁偏向在没有锁竞争的场合可以有较好的优化效果,对于锁竞争
比较激烈的场景,效果不佳, 锁竞争激烈的情况下可能是每次都是不
同的线程来请求锁,这时偏向模式失效.

2. 轻量级锁

如果锁偏向失败,JVM 不会立即挂起线程,还会使用一种称为轻量
级锁的优化手段. 会将对象的头部作为指针,指向持有锁的线程堆栈
内部, 来判断一个线程是否持有对象锁. 如果线程获得轻量级锁成功,
就进入临界区. 如果获得轻量级锁失败,表示其他线程抢到了锁,那么
当前线程的锁的请求就膨胀为重量级锁.当前线程就转到阻塞队列中
变为阻塞状态.

偏向锁,轻量级锁都是乐观锁,重量级锁是悲观锁

一个对象刚开始实例化时,没有任何线程访问它,它是可偏向的,即
它认为只可能有一个线程来访问它,所以当第一个线程来访问它的时
候,它会偏向这个线程. 偏向第一个线程,这个线程在修改对象头成为
偏向锁时使用 CAS 操作,将对象头中 ThreadId 改成自己的 ID,之后再访
问这个对象时,只需要对比 ID 即可. 一旦有第二个线程访问该对象,因
为偏向锁不会主动释放,所以第二个线程可以查看对象的偏向状态,当
第二个线程访问对象时,表示在这个对象上已经存在竞争了,检查原来
持有对象锁的线程是否存活,如果挂了则将对象变为无锁状态,然后重
新偏向新的线程; 如果原来的线程依然存活,则马上执行原来线程的
栈,检查该对象的使用情况,如果仍然需要偏向锁,则偏向锁升级为轻
量级锁

轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对同
一个锁的操作会错开,或者稍微等待一下(自旋)另外一个线程就会释
放锁. 当自旋超过一定次数,或者一个线程持有锁,一个线程在自旋,又
来第三个线程访问时, 轻量级锁会膨胀为重量级锁, 重量级锁除了持
有锁的线程外,其他的线程都阻塞.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值