Java线程实现与安全

Java基础

浏览数:201

2019-5-9

AD:资源代下载服务

目录

1. 线程的实现

  线程的三种实现方式

  Java线程的实现与调度  

2. 线程安全

  Java的五种共享数据

  保证线程安全的三种方式

 

前言

本篇博文主要是是在Java内存模型的基础上介绍Java线程更多的内部细节,但不是简单的代码举例,更多的是一些理论概念,可以说是对自己的一种理论知识的补充

注:建议先了解Java的内存模型,再理解本篇博文效果更佳。具体可以看我的总结的关于Java内存模型的博文

本文主要参考《深入理解JVM》中高效并发编程部分

一、线程的实现

1、线程的三种实现方式  

  首先并发并不是我们通常我们认为的必须依靠线程才能实现,但是在Java中并发的实现是离不开线程的,线程的主要实现有三种方式:

  • 使用内核线程(Kernel Thread,KLT)实现
  • 使用用户线程实现

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

  (1)使用内核线程(Kernel Thread,KLT)实现:

  直接由OS(操作系统)内核(Kernel)支持的线程,程序中一般不会使用内核线程,而是会使用内核线程的高级接口,即轻量级进程(Light Weight Process,LWP),也就是通常意义上的线程。

每个轻量级线程与内核线程之间1:1的关系称之为一对一的线程模型。

  优点:每个LWP是一个独立调度单元,即使阻塞了,也不会影响整个进程。

  缺点:需要在User Mode与Kernel Mode中来回切换,系统调用代价比较高;由于内核线程的支持会消耗一定的内核资源,因此一个系统支持轻量级进程的数量是有限的。

  (2)使用用户线程实现:

  广义上来说,一个线程只要不是内核线程就可以认为是用户线程(User Thread,UT),但其实现仍然建立在内核之上;狭义上来说,就是UT是指完全建立在用户空间的线程库上,Kernel完全不能感到线程的实现,线程的所有操作完全在User Mode中完成,不需要内核帮助(部分高性能数据库中的多线程就是UT实现的)

  缺点:所有的线程都需要用户程序自己处理,以至于“阻塞如何解决”等问题很难解决,甚至无法实现。所以现在Java等语言中已经抛弃使用用户线程。

  优点:不需要内核支持

  (3)使用用户线程加轻量级进程混合实现:

  内核线程与用户线程一起使用的实现方式,而OS提供支持的轻量级进程则是作为用户线程与内核线程之间的桥梁。UT与LWP的数量比是不定的,是M:N的关系(许多Unix系列的OS都提供M:N的线程模型)

2、Java线程的实现与调度

  (1)Java线程的实现:

  OS支持怎样的线程模型,都是由JVM的线程怎么映射决定的。

  在Sun JDK中,Windows与Linux都是使用一对一的线程模型实现(一条Java线程映射到一条轻量级进程之中);

  在Solaris平台中,同时支持一对一与多对多的线程模型

  (2)Java线程调度:

  是指系统内部为线程分配处理使用权的过程,主要调度分为两种,分别是协同式线程调度和抢占式线程调度。

    1)协同式调度:线程执行时间由线程本身控制,线程工作结束后主动通知系统切换到另一个线程去。    

      ① 缺点:线程执行时间不可控,切换时间不可预知。如果一直不告诉系统切换线程,那么程序就一直阻塞在那里。

      ② 优点:实现简单,由于是先把线程任务完成再切换,所以切换操作对线程自己是可知的。

    2)抢占式调度:线程执行时间由系统来分配,切换不由线程本身决定,Java使用就是抢占式调度。并且可以分配优先级(Java线程中设置了10中级别),但并不是靠谱的(优先级可能会在OS中被改变),这是因为线程调度最终被映射到OS上,由OS说了算,所以不见得与Java线程的优先级一一对应(事实上Windows有7中,Solairs中有2的31次方)

二、线程安全

1、Java中五种共享数据

  (1)不可变:典型的final修饰是不可变的(在构造器结束之后),还有String对象以及枚举类型这些本身不可变的。

  (2)绝对线程安全:不管运行时环境如何,调用者都不需要任何额外的同步措施(通常需要很大甚至不切实际的代价),在Java API中很多线程安全的类大多数都不是绝对线程安全,比如java.util.Vector是一个线程安全容器,它的很多方法(get()、add()、size())方法都是被synchronized修饰,但是并不代表调用它的时候就不需要同步手段了。

  (3)相对线程安全:就是我们通常说的线程安全,Java API中很多这样的例子,比如HashTable、Vector等。

  (4)线程兼容:就是我们通常说的线程不安全的,需要额外的同步措施才能保证并发环境下安全使用,比如ArrayList和HashMap

  (5)线程对立:不管采用何种手段,都无法在多线程环境中并发使用。

2、线程安全的实现方法

(1)互斥同步(Mutual Exclision & Synchronization)

  同步:保证同一时刻共享数据被一个线程(在使用信号量的时候也可以是一些线程)使用。

  互斥:互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥手段。

  1)Java中最常用的互斥同步手段就是synchronized关键字,synchronized关键字经过编译后会在代码块前后生成monitorenter(锁计数器加1)与monitorexit(锁计数器减1)字节码指令,而这两个指令需要一个引用类型参数指明要锁定和解锁的对象,也就是synchronized(object/Object.class)传入的对象参数,如果没有参数指定,那就看synchronized修饰的是实例方法还是类方法,去取对应的对象实例与Class对象作为锁对象。

  Java线程要映射到OS原生线程上,也就是需要从用户态转为核心(系统)态,这个转换可能消耗的时间会很长,尽管VM对synchronized做了一些优化,但还是一种重量级的操作。

  2)另一个就是java.util.concurrent包下的重入锁(ReentrantLock),与synchronized相似,都具有线程重入(后面会介绍重入概念)特性,但是ReentrantLock有三个主要的不同于synchronized的功能:

    等待可中断:持有锁长时间不释放,等待的线程可以选择先放弃等待,改做其他事情。

    可实现公平锁:多个线程等待同一个锁时,是按照时间先后顺序依次获得锁,相反非公平锁任何一个线程都有机会获得锁。

    锁绑定多个条件:是指ReentrantLock对象可以同时绑定多个Condition对象。

  JDK 1.6之后synchronized与ReentrantLock性能上基本持平,但是VM在未来改进中更倾向于synchronized,所以在大部分情况下优先考虑synchronized。

(2)非阻塞同步

  1)“悲观”并发策略——非阻塞同步概念

    互斥同步主要问题或者说是影响性能的问题是线程阻塞与唤醒问题,它是一种“悲观”并发策略:总是会认为自己不去做相应的同步措施,无论共享数据是否存在竞争它都会去加锁。

    而相反有一种“乐观”并发策略,也就是先操作,如果没有其他线程使用共享数据,那操作就算是成功了,但是如果共享数据被使用,那么就会一直不断尝试,直到获得锁使用到共享数据为止(这是最常用的策略),这样的话就线程就根本不需要挂起。这就是非阻塞同步(Non-Blocking Synchronization)

    使用“乐观”并发策略需要操作和冲突检测两个步骤具有原子性,而这个原子性只能靠硬件完成,保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成。常用的指令有:测试并设置(Test-and-Set)、获取并增加(Fetch-and-Increment)、交换(Swap)、比较并交换(Compare-and-Swap,CAS)、加载链接/条件储存(Load-Linked/Store-Conditional,LL/SC)

  2)CAS介绍

有三个操作数,分别是内存位置V,旧的预期值A和新值B,CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则不更新,但是都会返回V的旧值,整个过程都是一个原子过程。

               

                 

    之前我在Java内存模型博文中介绍volatile关键字的在高并发下并非安全的例子中,最后的结果并不是我们想要的结果,但是在java.util.concurrent整数原子类( 如AtomicInteger)中,compareAndSet()与getAndIncrement()方法使用了Unsafe类的CAS操作。现在我们将int换成AtomicInteger,结果都是我们所期待的10000

 1 package cas;
 2 /**
 3  * Atomic 变量自增运算测试
 4  * @author Lijian
 5  *
 6  */
 7 import java.util.concurrent.ExecutorService;
 8 import java.util.concurrent.Executors;
 9 import java.util.concurrent.TimeUnit;
10 import java.util.concurrent.atomic.AtomicInteger;
11 
12 public class CASDemo {
13 
14     private static final int THREAD_NUM = 10;//线程数目
15     private static final long AWAIT_TIME = 5*1000;//等待时间
16     public static AtomicInteger race = new AtomicInteger(0);
17     
18     public static void increase() { race.incrementAndGet(); }
19     
20     public static void main(String[] args) throws InterruptedException {
21         ExecutorService exe = Executors.newFixedThreadPool(THREAD_NUM);
22         for (int i = 0; i < THREAD_NUM; i++) {
23             exe.execute(new Runnable() {
24                 @Override
25                 public void run() {
26                     for (int j = 0; j < 1000; j++) {
27                         increase();
28                     }
29                 }
30             });
31         }
32         //检测ExecutorService线程池任务结束并且是否关闭:一般结合shutdown与awaitTermination共同使用
33         //shutdown停止接收新的任务并且等待已经提交的任务
34         exe.shutdown();
35         //awaitTermination等待超时设置,监控ExecutorService是否关闭
36         while (!exe.awaitTermination(AWAIT_TIME, TimeUnit.SECONDS)) {
37                 System.out.println("线程池没有关闭");
38         }
39         System.out.println(race);
40     }
41 }

通过观察incrementAndGet()方法源码我们发现:

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

 通过for(;;)循环不断尝试将当前current加1后的新值(mext)赋值(compareAndSet)给自己,如果失败的话就重新循环尝试,值到成功为止返回current值。  

  3)CAS的ABA问题

    这是CAS的一个逻辑漏洞,比如V值在第一次读取的时候是A值,即没有被改变过,这时候正要准备赋值,但是A的值真没有被改变过吗?

    答案是不一定的,因为在检测A值这个过程中A的值可能被改为B最后又改回A,而CAS机制就认为它没有被改变过,这也就是ABA问题,解决这个问题就是增加版本控制变量,但是大部分情况下ABA问题不会影响程序并发的正确性。

(3)无同步方案

  “要保障线程安全,必须采用相应的同步措施”这句话实际上是不成立的,因为有些本身就是线程安全的,它可能不涉及共享数据自然就不需要任何同步措施保证正确性。主要有两类:

  1)可重入代码(Reentrant Code)

    也就是经常所说的纯代码(Pure Code),可以在任何时刻中断它,之后转入其他的程序(当然也包括自身的recursion)。最后返回到原程序中而不会发生任何的错误,即所有可重入的代码都是线程安全的,而所有线程安全的代码都是可重入的

    其主要特征是以下几点:

    ① 不依赖存储在堆(堆中对象是共享的)上的数据和公用的系统资源(方法区中可以共享的数据。比如:static修饰的变量,类的可以相关共享的数据),可以换句话说就是不含有全局变量等;

    ② 用到的状态由参数形式传入;

    ③ 不调用任何非可重入的方法。

  即可以以这样的原则来判断:我们如果能预测一个方法的返回结果并且方法本身是可预测的,那么输入相同的数据,都会得到相应我们所期待的结果,就满足了可重入性的要求。

  2)线程本地存储(Thread Lock Storage)

    如果一段代码中所需要的数据必须与其他代码共享,那么能保证将这些共享数据放到同一个可见线程内,那么无须同步也能保证线程之间不存在竞争关系。

    在Java中如果一个变量要被多线程访问,可以使用volatile关键字修饰保证可见性,如果一个变量要被某个线程共享,可以通过java.lang.ThreadLocal类实现本地存储的功能。每个线程Thread对象都有一个ThreadLocalMap(key-value, ThreadLocalHashCode-LocalValue),ThreadLocal就是当前线程ThreadLocalMap的入口。

    注:这里只是简单了解概念,实际上ThreadLocal部分的知识尤为重要!之后会抽时间细细研究。

作者:JJian