BiBi - JVM -13- 并发

From:深入理解Java虚拟机

  • 目录
    BiBi - JVM -0- 开篇
    BiBi - JVM -1- Java内存区域
    BiBi - JVM -2- 对象
    BiBi - JVM -3- 垃圾收集算法
    BiBi - JVM -4- HotSpot JVM
    BiBi - JVM -5- 垃圾回收器
    BiBi - JVM -6- 回收策略
    BiBi - JVM -7- Java类文件结构
    BiBi - JVM -8- 类加载机制
    BiBi - JVM -9- 类加载器
    BiBi - JVM -10- 虚拟机字节码
    BiBi - JVM -11- 编译期优化
    BiBi - JVM -12- 运行期优化
    BiBi - JVM -13- 并发

基于【高速缓存】的存储交互很好地解决了处理器与内存的速度矛盾,同时也引入了一个问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一个主内存,当多个处理器的运算任务涉及到同一块主内存区域时,将会导致数据缓存不一致。所以,要遵守缓存一致性协议,在读写的时候要根据协议来进行操作。

Java内存模型 - JMM

Java虚拟机通过Java内存模型【Java Memory Model,JMM】来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。【C/C++直接使用物理硬件和操作系统的内存模型】

JVM可以利用硬件的特性有:寄存器、高速缓存、指令集中某些特有的指令。
内存间交互的8个操作:
lock
unlock
read
load
use
assign
store
它们都是原子的、不可再分的【对于long和double类型的变量JVM允许为非原子操作,但现在的商用JVM都选择把64位数据的读写作为原子操作来对待。所以,一般不需要把long和double专门声明为volatile】。

把一个变量从主内存复制到线程的工作内存,执行read >> load;把一个变量从线程的工作内存同步回主内存,执行store >> write。

注意:Java内存模型只要求上述两个操作必须按照顺序执行,而没有保证是连续执行的。所以,可能出现顺序为:read a、read b、load b、load a。

对一个变量执行unlock操作之前,必须先把此变量同步回主内存中。

volatile

volatile变量在各个线程的工作内存中不存在一致性问题,对所有线程都可见。但Java里面的运算并非原子操作【如:++i】,导致volatile变量的运算在并发下一样不是安全的。

一条字节码指令,不能代表执行这条指令是一个原子操作。

volatile变量能够禁止指令重排序优化。

对一个volatile变量的写操作【先行发生】于后面对这个变量的读操作。
问题:线程A先调用setValue(7),然后线程B调用同一个对象的getVale(),那么线程B会收到什么值?

  private int value = 0;

  public int getValue() {
    return value;
  }

  public void setValue(int value) {
    this.value = value;
  }

答案:不确定,0 或 7。如果改为private int volatile value = 0;结果一定为7。

final的可见性:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this引用传递出去,即没有发生逃逸,那在其他线程中就能看见final字段。【因为该字段以后只能被读,不能再被修改】【不可变对象一定是线程安全的,前提没有this逃逸】

不可变对象,最简单的就是把对象中的成员都设为final,如:Integer中的private final int value;

线程

线程可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源【如:内存地址、文件IO】,又可以独立调度。

Java虚拟机的线程模型:JDK1.2之前是【用户线程实现的】,在JDK1.2之后,线程模型替换为【基于操作系统原生线程模型】来实现。如:Sun JDK,在Widows和Linux都是使用一对一的线程模型来实现的,即一条Java线程就映射到一条轻量级进程中,因为Widows和Linux系统提供的线程模型就是一对一的。

由于Java的线程是映射到操作系统原生线程上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户状态转换到内核状态,因此状态转换需要耗费很多的处理器时间。

线程调度

协同式线程调度和抢占式线程调度。
协调式:线程的执行时间由线程本身来控制,线程把自己的工作执行完后,要主动通知系统切换到另外一个线程上。【没有线程同步问题】。缺点:当一个线程编写有错误,导致一直不告诉系统进行线程切换,那就会一直阻塞。即一个进程坚持不让出CUP执行时间,使整个系统崩溃。

抢占式【Java使用的线程调度方式】:每个线程由系统来分配执行时间,线程的切换不由线程本身决定。此种情况,线程的执行时间是系统可控的,不会出现让一个线程导致阻塞整个进程。

线程安全

对于线程安全的容器,使用其方法时,也要注意同步问题,如下:Vector是安全容器,remove()和get()方法都是同步方法。

public void testSync() {
  final Vector vector = new Vector(10);
  new Thread(new Runnable() {
    @Override
    public void run() {
      for (int i = 0; i < vector.size(); ++i) {
        vector.remove(i);
      }
    }
  }).start();
  new Thread(new Runnable() {
    @Override
    public void run() {
      for (int i = 0; i < vector.size(); ++i) {
        vector.get(i);
      }
    }
  }).start();
}

上面的例子,会产生数组越界问题,解决方案如下:

public void testSync() {
  final Vector vector = new Vector(10);
  new Thread(new Runnable() {
    @Override
    public void run() {
      synchronized (vector) {
        for (int i = 0; i < vector.size(); ++i) {
          vector.remove(i);
        }
      }
    }
  }).start();
  new Thread(new Runnable() {
    @Override
    public void run() {
      synchronized (vector) {
        for (int i = 0; i < vector.size(); ++i) {
          vector.get(i);
        }
      }
    }
  }).start();
}

同步:互斥/阻塞同步、非阻塞同步
在JDK1.6之后,synchronized与ReentrantLock的性能基本上完全持平,因此若基于性能因素而言,不再选择ReentrantLock了,而是偏向于使用synchronized。

由于阻塞同步需要:加锁、用户状态内核转换、维护锁计数器、检查是否有被阻塞的线程需要唤醒等,这些都会造成性能问题,所以这是一种悲观的并发策略【原因:总是认为只要不做同步处理,那就肯定会出现问题】。

随着硬件指令集的发展,出现了【基于冲突检测】的乐观并发策略,即非阻塞同步
定义:先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施,如:不断地重试,直到成功为止。该种方式不需要把线程挂起。

  • 为什么非阻塞同步是随着【硬件指令集的发展】才实现呢?

因为操作和冲突检测这两个步骤需要具备原子性,那靠什么来保证呢?如果这里再使用互斥同步来保证就失去意义了,所以只能依靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,如:比较并替换【Compare And Swap,CAS】

体会CAS的背景和应用。

代码体会:

public final int incrementAndGet() {
  for (; ; ) {
    int current = get();
    int next = current + 1;
    if (compareAndSet(current, next)) {
      return next;
    }
  }
}

你可能感兴趣的:(BiBi - JVM -13- 并发)