并发编程1 基本概念

前言: 学习了几章《java 并发编程实战》 的内容,其实对我来说还是有难度的,特别是前面几章基础的概念,平时的编程都是基于单线程的编程,在编写的过程中也从未考虑过多线程安全的问题,再加上,前几章基础知识的介绍是特别细致,毫无封装的操作下的多线程安全问题

关注的是对象或者静态域中的数据,一个对象是否安全取决于它是否被多个线程访问。在Java中使用同步机制来控制线程对对象的访问。
访问一个变量的代码越少,就越容易保证访问都实现同步


PART ONE

编写安全代码的核心:

多线程情况下对状态访问操作进行管理,特别是一些共享和变化的状态访问

状态:一般是对象状态,指的是对象中存储的状态量(实例或者静态域)中的数据,包括其他依赖该对象的域
共享:意味着该变量可以被多个线程同时访问
变化:意味着该变量在它的生命周期内可以发生改变
对数据的操作并非原子性操作,多线程并发访问(CPU 会随机的切换)

多线程带来的风险:

  1. 安全性问题
    一个线程在访问一个共享数据的时候(修改),另一个线程也在对这个数据进行访问

    多线程中操作的执行顺序是不可预测的,会产生不可预知的结果(指令重排导致)线程间的数据修改存在不可见性结果也会出现错误。

    1. 指令重排
      计算机执行指令的顺序是在经过程序编译器编译之后形成给的指令序列,一般情况下CPU和编译器为了提高程序执行的效率,会按照一定的规则进行指令优化,重排指令的顺序。

      单线程中指令重排的规则:
      数据依赖性:指令前后存在依赖关系,重排之后代码的逻辑会被改变,所以不允许这样的重排。因此在单线程下的重排不会导致代码的逻辑错误

      a = 1 ; b = a ; //先读后写
      a = 1 ; a = 2 ; //先写这个变量之后再写这个变量
      a = b ; a = 1 ; //读取这个变量之后再写这个变量
      

      重点:这样的规则在单线程中是遵循的,但是在多线程执行的程序中,代码都是可以进行重排的,这样就导致了代码的逻辑出现问题
      2.可见性
      可见性的描述要基于JVM 的内存模型:

  2. 活跃性问题:主要是死锁,饥饿,活锁的情况

  3. 性能问题:活跃性的关注点在:事情总会发生,性能的关注点在希望事情尽快发生
    线程调度器临时挂起活跃线程转而运行另一个线程,就会频繁的出现上下文的切换操作,这样的操作开销极大,保存和回复上下文,CPU将更多的时间花费在线程的调度而非线程的执行。当线程共享数据时,必须使用同步,这回抑制某些编译器的优化,使缓存区中的数据无效


PART TWO

基于同步来避免多线程在同一时刻访问相同的数据,介绍相关的基本概念

问题一: 竞态条件

public class Test{
	private int count=0;
	public int getCount(){
	return count;
	}
	public void test(){
		count++;
	}
}

这段代码在单线程的情况下时可以正确的执行的,但是在多线程的情况下并非线程安全,有可能会出现问题。count++ 看似是一个操作,但是他不是一个原子操作,它包含了三步:读取count 的值,将读取出来的值+1,再将计算的结果写回count 中

这段代码在多线程情况下执行会出现的问题是:多个线程执行count++ ,getCount() 之后得到的值是一样的
详解:假设count 的初始值为9 ,在某些情况下每个线程获取到数据都是9 ,接着执行递增的操作最后的结果都是10.这样的结果不是我们所期望的。

竞态条件
当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竟态条件

  1. 先检查后执行: 基于检查的结果来判断是否执行另一些操作

    //示例: 延迟初始化(建议不要这样写)
    public class Test{
       private obj;
       public Test(){
    		if(obj==null){
    			obj = new Object;	
    		}
    	}
    }
    //这是一个经典的懒汉模式,在多线程不安全的模式
    
  2. 读取–修改–写入,操作中,每一个线程的操作要基于其他线程的操作结果(例如上例对count 的修改)
    这个过程在多线程的情况下会出现问题的原因:我们的意识里,这是一个原子操作(count++),但是在JVM中实现的时候这就是一个复合操作。cout++ 的例子

问题二: 可见性

可见性:

可见性要基于上述的JVM的内存模型,同时是在多线程的情况下。

在多线程的情况下,不可见不是必然的,只是可能会发生,之所以会关注这个可能会发生的情况是因为,多线程的情况下肯定是高并发的,不可见发生的可能性就会很大,并且一旦这样的情况发生后果很严重。

经典示例:

public class NoVisibility {
  public static boolean ready;
  public static int number;

  private static class Read extends Thread{
      @Override
      public void run() {
          System.out.println(Thread.currentThread().getName()+"  "+number);
          while (!ready){
              Thread.yield();
              System.out.println(number);
          }
      }
  }
  public static void main(String[] args){
     
       Read read = new Read();
       read.start();
       number = 42;   
       ready = true;      
  }
}

可能出现的额错误结果,while 语句一直循环,因为看不见主线程修改的值
为什么会看不见,主要是基于JVM的内储存模型

失效性

不可见性还引出了一个问题:数据失效

public class MutableInteger{
	private int value;
	public int get(){return value;}
	public void set(int value){this.value = value;}
}

这是一个非线程安全的整数类,如果要让他变为一个线程安全的类,仅仅对set 方法同步是不够的还要对get 方法也进行同步

这里对set 方法同步之前自己是可以理解的,但是无法理解为什么还要对get 方法进行同步,为什么不同步的get 方法可能会获取失效值
这里注意理解失效值,这的失效是指对当前的操作而言获取的值是没有意义的
对于set 不同步就会导致数据还没有写道主存中线程,其他的线程就对主存进行读取,获取的值就并非是实时更新的value 的值,而是之前的某个值--------所以是失效的
对于get 执行了read 但是还未来得及执行load 线程就被切换出去,如果此时有同步的set 线程对主存中的value值进行了修改,再切回read 线程执行load。读取的数据就是之前的数据,get 操作的意义是获取当前value 的值,但是在这样的情境下,有两个线程先后对value 的值进行了修改,对于get 来说他并未取得value每次的最新值

总结:★★

多线程访问一个共享的数据存在的多线程安全问题,本质原因两点:

  1. 对数据的操作不是原子操作
  2. 在多线程的情况下代码会进行很可能破坏代码逻辑的重排性能优化

无论是使用同步关键字还是volatitle 关键字,本质上就是将由原子操作组合的复合操作变成一个原子操作(单线程操作),并且在unlock 之前修改的数据要更新到主存中
整个过程就是:在当前线程没有彻底完成自己的工作前,不允许其他的线程访问该数据


解决多线程访问共享数据,线程安全的措施

1.Volatitle 变量

volatitle 是一种轻量级的同步机制,把变量声明为volatitle 之后,编译和运行时,不会将该变量上的操作进行重排(禁止重排),volatitle 变量在被修改之后可以立 即同步到主存中,因此在读取volatitle 时总会返回最新的值。同时使用 volatitle 变量不会加锁,因此就不会是执行线程阻塞,也不会有锁的判断之类的,所以volatitle 更加的轻量级。
synchronized 是通过锁的方式每次进行操作的只有一个线程,释放锁之前数据一定会被更新到主存,同时相当于单线程的执行情况下,重排必须以不影响结果为前提。
;但是volatitle 不是这样的机制,不会重排同时改变了这类变量读写的内存实现,它的修改是直接在主存,在每个线程都看得见的地方实时的修改。

Volatitle 的特殊性

volatitle 是在读,取,赋值修改是线程安全的,但是对volatitle 的运算不是是线程安全的,比如++ 的运算,对于volatitle 来说就无法保证是线程安全的(执行引擎看到的数据无法保证一致性)

volatitle 使用的情景:表示标志状态量。保证不要出现通过运算对他的修改。

2.加锁机制:

  1. 关键字:synchronized
    1. 可以将关键字直接加在方法上,表示这个方法是同步的。线程安全的。
    2. 使用synchronized 代码块(内置锁)

    	synchronized(lock){...}
    

    同步代码块包含两个部分:作为锁的对象;被这个锁保护的代码块

    1. 单个的变量修改:使用Atomicxxx修饰
    AtomicInteger num = new AtomicInteger(1); 
    //相当于 int a = 1
    
     Atomaticxxx 修饰的变量就表示他是线程安全的。
    
  2. 锁保护状态
    使用所实现了同步,同时也可以理解为锁保护了对象的状态(数据)/这里要注意的是,在访问被保护状态的所有位置都要使用同步,并且要使用同一个锁。
    线程在访问可变/共享状态时,会首先获取锁,这样就确保了其他线程无法访问被保护的变量,在不可变性条件中每个变量都必须使用同一个锁来保护。

  3. 重入:
    (重入)如果一个线程想要获取一个自己已经持有的锁那么这个请求就会成功,“重用”意味着获取锁操作的粒度是“线程”,而不是调用。重用提高了代码的封装性简化了面向对象的编程

    public class Widget{
    	public synchronized void doSomething(){...}
    }
    public class LoggingWidget extends Widget{
    	public synchronized void doSomething(){
    		super.doSomething();
    	}
    }
    

    如果没有重入,那么这段代码将会死锁,子类重写了doSomething() 方法,获得了这个方法的锁,如果没有重入,那么这段代码永远无法获取父类super.doSomething() 的锁,因为这个锁已经被持有。

  4. 活跃性与性能
    并不是所有的变量都需要同步/保护,要将同步代码块控制在一个合适的大小


PART THREE

count++ 明明就是一行代码为什么是一个多原子的复合操作

JVM的内存模型

JVM 的内存模型和JVM的内存分区不是一个级别上的模型划分,他们之间没有什么联系

在这里插入图片描述

基于可见性的JVM模型分析:
对于一个变量的读写这个过程中的原子操作:

所有变量都存储在主内存中,每个线程都有自己独立的工作内存,里面保存该线程使用到的变量副本,即主内存中该变量的一份拷贝。

线程对共享变量的所有操作必须在自己的工作内存,线程间变量值的传递需要通过主内存来完成。

在多线程的程序中,每个线程有自己的运行空间,没有同步,各个线程之间的状态是不可见的

JVM内存模型和线程: https://blog.csdn.net/json_it/article/details/79218297

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值