【Java进阶】03-线程(下)

线程安全的概念

线程安全指的不是线程是否安全,而是指整个程序运行的正确性和安全性。

当多个线程访问同一个对象的时候,如果不用考虑这些线程在运行环境下的调度和交替执行,也不用进行额外的同步,也不用调用方进行任何其他的协调操作,调用这个对象的行为也可以获得正确的结果,那么这个对象是线程安全的。 —— Brian Goetz <Java Concurrency in Parctice>

简而言之就是结果的正确性不受多个线程调度顺序的影响,在任何地方中断也没事。

线程安全分为如下几个类型:不可变、绝对线程安全、相对线程安全

不可变

如果线程访问的对象的数据是不可变的,则代码的执行次序和线程的执行次序自然不会影响结果。所以如果对象不可变,则这个对象是线程安全的。

不可变的类型

  • 使用 final 关键字修饰的属性,如 public final int a = 100;
  • 字符串类型,字符串的内容是存放于堆中的,修改字符串实际就是改变一个字符串引用指向的内容,而字符串本身相当于常量,是不可变的。java.lang.String s = "hello"; s = s+" world;"这个操作实际上是将 s 指向了另一个对象。
  • 枚举类型:public enum Color {RED, GREEN, BLANK, YELLOW};,枚举类型天然也是 final 类型,一旦定义就不可以修改
  • java.lang.Number 的子类,如 Long, Double, Integer 等
  • BigInteger, BigDecimal 等高精度的数值类型

绝对线程安全

一个绝对安全的线程应该要满足上述 Brian Goetz 进行的定义,否则就不是绝对地线程安全。Java API 中标注自己是线程安全的类绝大部分不是绝对地线程安全,如 java.util.Vector,而是相对线程安全。

相对线程安全

相对线程安全是通常意义上的线程安全,其保证了这个对象的单独操作是线程安全的,调度的时候不需要进行二外的保护措施。但是不排除一些特性顺序的连续调用可能会出错,因此需要使用同步手段保证调用的正确性。

相对线程安全的类的例子 Vector, HashTable 等。

下面展示一下 Vector 出错的例子

import java.util.*;

public class Exp1 {
    private static Vector<Integer> vector = new Vector<>();

    public static void main(String[] args) {
        while(true){
            for(int i=0; i<10; i++){
                vector.add(i);
            }

            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int i=0; i<vector.size(); i++) vector.remove(i);
                }
            });

            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int i=0; i<vector.size(); i++) System.out.println(vector.get(i));
                }
            });

            removeThread.start();
            printThread.start();
            while(Thread.activeCount()>20); // 这一行是为了控制一起运行的线程数量不超过20个
        }
    }
}

程序一般情况下会正常运行,如果运行足够长的时间,会看到有如下的报错

Exception in thread “Thread-187” java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 14
at java.util.Vector.get(Vector.java:753)
at com.company.chap3one.Exp1$2.run(Exp1.java:24)
at java.lang.Thread.run(Thread.java:748)

问题就出在程序中有一个读线程和一个删除线程。当读的线程刚好要读的数刚好被删除线程删除时,就会报出上述的数组下标越界的错误。

这个程序里面使用了匿名内部类的技术。分别创建了两个类实现了 Runnerable 接口,然后重写了里面的 run() 方法,然后作为 Thread 类创建的参数,由 Thread 类的引用指向它们。实际上,这是使用了多态的方法。新的类可以看作是实现了 Runnerable 接口,其实即使是如下的程序也是一样的。

Thread removeThread = new Thread() {
    @Override
    public void run() {
        for(int i=0; i<vector.size(); i++) vector.remove(i);
    }
};

Thread printThread = new Thread(){
    @Override
    public void run() {
        for(int i=0; i<vector.size(); i++) System.out.println(vector.get(i));
    }
};

上面的匿名内部类实际上是创造了一个继承了 Thread 类的匿名类,并且重写了 run() 方法。我们前面说过,基类的引用可以指向派生类,且使用基类的引用调用一个方法时,如果被派生类重写了,也会优先调用派生类重写的方法,而不会显式地调用自己本来定义的方法。这就实现了匿名内部类的机制。

解决这个错误的方法也很简单,可以使用上一篇介绍的 synchronized 关键字。重写如下:

Thread removeThread = new Thread() {
    @Override
    public void run() {
        synchronized (vector){
            for(int i=0; i<vector.size(); i++) vector.remove(i);
        }
    }
};

Thread printThread = new Thread(){
    @Override
    public void run() {
        synchronized (vector){
            for(int i=0; i<vector.size(); i++) System.out.println(vector.get(i));
        }
    }
};

当然,也可以将 synchronized 放到循环里面,这样可以实现更细粒度的删除和打印。

线程兼容和线程对立

  • 线程兼容:线程本身是不安全的,但是在调用端使用了正确的同步手段保证了对象在并发环境中可以安全使用
  • 线程对立:无论调用端使用了怎样的同步措施,都无法保证在多线程环境中安全地并发使用代码

Java 中线程对立的例子: Thread 类的 suspend() 和 resume() 方法。旧版的 Java 使用这两个方法将线程暂停和继续,但是已经证明这不是一种绝对线程安全的做法,可能会导致死锁。所以 suspend() 和 resume() 方法对立,所以 JDK 已经明确表示将其废弃。(现在方法前面加入了 @Deprecated 的都是已经被 JDK 废弃的方法。)

线程安全的实现方式——互斥同步

接下来介绍实现线程安全的三种方案,包括:

  • 互斥同步
  • 非阻塞同步
  • 无同步方案

之前介绍过的使用 synchronized 关键实现的同步就是互斥同步。事实上,任何使用了①临界区(Critical Section) ②互斥量(Mutex) ③信号量(Semaphore) 的方法都是互斥同步。使用了 synchronized 关键字之后,在经过编译之后,会在同步的代码块前后分别增加 monitorenter 和 monitorexit 两个字节码。表示对监视器的进入和退出。

synchronized 代码块有如下特性:

  • 对自己是可重入的,即不会锁住自己
  • 同步代码块进入之后,执行完之前,会阻塞同一对象后面试图进入的线程。

别的互斥锁

使用重入锁 ReentrantLock (java.util.concurrent.locks.ReentrantLock) 也可以实现互斥同步。这是在 API 层面实现的互斥锁,而 synchronized 表现为原生语法层面的锁。重入锁相比 synchronized 可以实现:等待的可中断行、公平锁、锁可绑定多个条件等特性。

实现重入锁的例子如下

import java.util.Vector;
import java.util.concurrent.locks.ReentrantLock;

public class Exp2 {
    private ReentrantLock lock = new ReentrantLock();

    private Vector<Integer> vector = new Vector<>();
    private final int capacity = 10;


    private Thread addThread = new Thread(){
        @Override
        public void run() {
            lock.lock();
            try{
                System.out.println("Start writing");
                for(int i=0; i<capacity; i++){
                    vector.add(new Integer(i+1));
                }
                System.out.println("Done");
            }finally {
                lock.unlock();
            }
        }
    };

    private Thread readThread = new Thread(){
        @Override
        public void run() {
            try{
                lock.lockInterruptibly();
                System.out.println("Start reading");
                for(int j=0; j<vector.size(); j++){
                    System.out.println(vector.get(j));
                    vector.remove(new Integer(j));
                }
            }catch (InterruptedException e){
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    };


    public static void main(String[] args) throws Exception{
        Exp2 e = new Exp2();
        e.addThread.start();
        e.readThread.start();
    }
}

运行结果如下:

Start writing
Done
Start reading
1
2
4
6
8
10

同时,使用 ReentrantLock 相比 synchronized 在性能上还有一定的提升。
在这里插入图片描述
可见,使用 ReentrantLock 在无论是单 CPU 还是多 CPU 的系统中,吞吐量都比较均衡,而 synchronized 关键字实现的多线程系统的吞吐量在线程数量比较多的时候就会下降得比较厉害。

非阻塞同步

  • 阻塞同步 (Blocking Synchronized) 如上一种互斥同步,会进行线程的阻塞和唤醒,对系统的性能产生影响,这是一种悲观的并发策略
  • 非阻塞同步不同于悲观的并发策略,而是一种基于乐观的基于冲突检测的并发策略。实现的思路就是先执行并发操作,如果没有其它线程征用共享数据,则操作成功;否则就产生了冲突,采取不断重试直到成功为止的策略。这种策略不用把线程挂起,称为非阻塞同步。

非阻塞同步需要一些硬件指令的支持,比如一些不断重试的指令。(JDK 1.5之后实现了一些非阻塞同步的方法)
一些支持非阻塞同步的指令:

  • 测试并设置(Test and Set)
  • 获取并增加(Fetch and Increment)
  • 交换(Swap)
  • 比较并交换(Compare and Swap,CAS)
  • 加载链接(Load Linked,LL)
  • 条件存储(Store conditional,SC)

实现非阻塞同步主要是使用 Java 里面实现的一些在底层实现了非阻塞同步机制的类,如 AtomicInteger,AtomicDouble 等。

比较如下两个类

class Counter{
    private volatile int count=0;
    public synchronized void increment(){
        count++;
    }
    public int getCount(){
        return count;
    }
}

class AtomicCounter{
    private AtomicInteger count = new AtomicInteger();
    public void increment(){
        count.incrementAndGet();
    }
    public int getCount(){
        return count.get();
    }
}

两者都实现计数和返回数量的功能,前一个类使用 synchronized 关键字做互斥同步,后一个类使用 AtomicInteger 对象,而没有显示地做同步操作。在 Counter 类里面,我们看到了使用 volatile 关键字修饰 count 属性。volatile 关键字的作用是在并发环境里面,使得其修改对所有线程可见。volatile 关键字只能修饰属性,当该属性被修改时,在同一个对象上建立的线程都能够得到通知——该属性的值被更新了。并且不仅在内存中修改了该值,在缓存中也将该值同步过去,这样就保证了同步环境下属性的一致性。

AtomicCounter 类中的 count 属性是 AtomicInteger 类,是个实现了非阻塞同步的类,实现机制已经在类定义的时候实现了,所以不需要在调用的方法里面做同步处理。两者都可以都得正确的结果。

无同步方法

首先定义一下什么是可重入代码。可重入代码也叫纯代码。纯代码指那些可以不加同步控制的代码,意思是在代码的任何部分切换到别的线程的任何部分运行,控制权回来之后原来的程序都不会出现错误。

线程本地存储:如果一段代码中的所需要的数据必须和其它代码共享,那么就要看看能不能把这些共享的代码放到同一个线程中执行,如果能保证,就可以把共享数据的可见范围限制在同一个线程之内,这样无需同步也能保证线程之间不出现数据争用的问题。说白了,就是可以将一些属性在每个线程中都有一个副本,每个线程自己可以自己的副本,但是副本之间不受影响。这个有点像局部变量,但是方法内的局部变量方法之间不可访问,一般的属性又会被其它线程访问,所以可以使用线程本地存储解决这些问题。

ThreadLocal

ThreadLocal 类相当于一个包装器,将一些属性包装为线程本地存储的。一般 ThreadLocal 类的属性会设置为静态私有的。多次使用的内部类一般也会设置为静态的。

举例如下:

public class Exp4 {
    private static ThreadLocal<Integer> seqNum = new ThreadLocal<Integer>(){
        public Integer initialValue(){
            return 0;
        }
    };

    public int getNextNum(){
        seqNum.set(seqNum.get()+1);
        return seqNum.get();
    }

    public static void main(String[] args) {
        Exp4 sn = new Exp4();
        TestClient t1 = new TestClient(sn);
        TestClient t2 = new TestClient(sn);
        TestClient t3 = new TestClient(sn);
        t1.start(); t2.start(); t3.start();
    }

    private static class TestClient extends Thread{
        private Exp4 sn;
        public TestClient(Exp4 sn){
            this.sn = sn;
        }

        @Override
        public void run() {
            for(int i=0; i<3; i++){
                System.out.println("thread["+Thread.currentThread().getName()+"] sn["+sn.getNextNum()+"]");
            }
        }
    }
}

结果如下:

thread[Thread-0] sn[1]
thread[Thread-1] sn[1]
thread[Thread-2] sn[1]
thread[Thread-1] sn[2]
thread[Thread-0] sn[2]
thread[Thread-1] sn[3]
thread[Thread-2] sn[2]
thread[Thread-0] sn[3]
thread[Thread-2] sn[3]

可见本地存储的线程相互之间不会干扰,即使是使用了相同的对象构造的线程。

我们定义了一个 ThreadLocal 对象 seqNum,覆盖了其中的 initialValue() 方法,该方法用于给其赋初值。ThreadLocal 包裹了一个 Integer 类,相当于初始化该 Integer 对象为0。其中也定义了一个 getNextNum() 方法,这个里面用到的 set(),get() 方法用于给 Integer 的值修改和读取。

锁优化

锁优化主要有如下几种:

  • 自旋锁
  • 自适应锁
  • 锁消除
  • 锁粗化
  • 偏向锁

自旋锁

互斥同步存在的问题:线程的挂起和恢复都需要转入内核态中完成,这些操作对系统的性能产生了很大的压力
自旋锁:如果多个处理机可以保证两个及以上的线程可以并行执行,那么如果同时运行的线程都想要对同一个对象上锁,我们可以不立即将锁转让给后请求锁的线程,而是让其等待一会儿,让正在占有锁的线程再多持有锁一会儿,但是并不让后请求的线程放弃 CPU。而是让后请求的线程进入一个忙循环(自旋)等待,这项技术就是自旋锁。这样就减少了线程切换的频率,降低了开销,从而利用率得到了提升。Java中的自旋锁默认自旋次数是10次。

自适应锁

自适应意味着自旋的时间不再固定,因为机械地设置自旋次数似乎是一个没什么依据的事。自适应采用不固定自旋时间的方式,自旋时间由前一次同一个锁上的时间和锁持有者的状态决定。如果同一个锁对象上,自旋等待刚刚可以成功获得锁,且前一刻的线程还没有运行完,则虚拟机就会认为再次自旋很有可能再次成功,所以会设置自旋的时间再延长一些。

所消除

JVM 会对代码上要求同步,实际上不会引起共享数据的竞争的锁进行消除。

判断依据:如果一段代码中,堆上所有的数据都不会逃逸出去被其它线程访问到,就可以把它们当作栈上的数据对待,认为它们是线程私有的,同步加锁自然无需进行。

锁粗化

通常我们会将临界区设置得更加小,使得可以同时访问的范围更大。实际上如果对一个对象连续反复地加锁,甚至是循环加锁,就算没有线程的争用,频繁地进行互斥同步也会导致不必要的性能损耗,所以我们可以适当扩大同步块的范围,使得加锁的次数减少,从而提升性能的利用率。这就是锁粗化。

偏向锁

偏向锁是在对象访问没有竞争的情况下消除同步操作的一种技术,甚至连 CAS(Compare and Swap) 都不做。偏向锁会偏向第一个获取它的线程,如果在接下来的执行中,该锁没有被其它线程获取,则持有偏向锁的线程不再需要进行同步。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值