JVM学习记录-线程安全与锁优化(一)

前言

线程:程序流执行的最小单元。线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。

Java语言定义了5中线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,5中状态如下。

新建(New):创建后尚未启动的线程处于这种状态。

运行(Runnable):Runnable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程可能正在执行,也可能正在等待着CPU为它分配执行时间。

无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显示地唤醒。

让线程进入无限等待的方法有如下几个:

  • 没有设置Timeout参数的Object.wait()方法。
  • 没有设置Timeout参数的Thread.join()方法。
  • LockSupport.park()方法。

限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。

让线程进入限期等待状态的方法有如下几个:

  • Thread.sleep()方法。
  • 设置了Timeout参数的Object.wait()方法。
  • 设置了Timeout参数的Thread.join()方法。
  • LockSupport.parkNanos()方法。
  • LockSupport.parkUntil()方法。

阻塞(Blocked):线程被阻塞了,“阻塞状态”是在等待着获取到一个排他锁,这个事件将在另一个线程放弃这个锁的时候发生;更通俗的解释就是一个线程正在干着一件事,没资源干其他的事,当来了其他的事时就只能阻塞的等着线程能腾出时间来处理。

结束(Terminated):已终止线程的线程状态,线程已经结束执行。

这5种状态在遇到特定的事件的时候会相互转换。

线程安全

一个比较严谨线程安全定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确地结果,那么这个对象就是线程安全的。

Java语言中的线程安全

研究线程安全,需要限定于多个线程之间存在共享数据访问这个前提。Java语言中各种操作共享数据分为以下5类:

不可变

在JDK1.5以后,Java语言中不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。如果一个基本数据类在定义时使用final关键字修饰它,就可以保证它时不可变的。如果final修饰的是一个对象,需要保证对象的方法不会对其状态产生影响才行。例如:String类的substring()、concat()这些方法不会影响原来的值,只会生成一个新的字符串。

保证对象方法不会对其状态产生影响的实现方式有很多,最简单是将对象中带有状态的属性用final修饰。

例如Integer类中的实现代码:

   /**
     * The value of the {@code Integer}.
     *
     * @serial
     */
    private final int value;

    /**
     * Constructs a newly allocated {@code Integer} object that
     * represents the specified {@code int} value.
     *
     * @param   value   the value to be represented by the
     *                  {@code Integer} object.
     */
    public Integer(int value) {
        this.value = value;
    }

Java中除了String类、Integer类,还有其他的Long、Double等包装类,以及BigInteger和BigDecimal等大数据类型,都符合不可变要求的类型。

绝对线程安全

绝对线程安全,是指绝对的符合前面提到的线程安全的定义,多线程永远调用对象时永远都能获得正确的结果。但是为了实现这个绝对要付出的代价是很大的,在Java中标注自己是线程安全的类,绝大多数都不是绝对线程安全的,例如Vector类,java.util.Vector是一个线程安全类,它的add()、get()、size()等都是被synchronized修饰的,但这并不能保证它是绝对安全的。

如下代码:

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

    public static void main(String[] args){
        

        while (true){
            for(int index = 0;index < 10;index++){
                vector.add(index);
            }
            //移除元素的线程
            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()>5);
        }


    }
    

}

运行结果:

Exception in thread "Thread-229" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 15
    at java.util.Vector.get(Vector.java:748)
    at com.eurekaclient2.client2.shejimoshi.JVM.Test$2.run(Test.java:38)
    at java.lang.Thread.run(Thread.java:748)

尽管Vector的方法都是同步的,但是在多线程环境下,若不在调用方法端做额外的同步措施的话,仍然不是线程安全的,因为若另一线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用的话,再用i访问数组就会抛出一个ArrayIndexOutOfBoundsException。

解决方法如下(将移除和打印都设置为同步):

public class Test {

    private static Vector<Integer> vector = new Vector<Integer>();

    public static void main(String[] args){


        while (true){
            for(int index = 0;index < 10;index++){
                vector.add(index);
            }
            //移除元素的线程
            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    synchronized(vector){
                        for (int i = 0; i<vector.size(); i++){
                            vector.remove(i);
                        }
                    }
                }
            });

            //打印元素的线程
            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    synchronized(vector){
                        for (int i = 0; i<vector.size(); i++){
                            System.out.println(vector.get(i));
                        }
                    }
                }
            });

            removeThread.start();
            printThread.start();

            while (Thread.activeCount()>5);
        }


    }


}

相对线程安全

我们通常所讲的线程安全就是指的相对线程安全,需要保证对象单独的操作时线程安全的,不需要做额外的保障措施。但若是对于一些特定属性的连续调用,就可能会需要在调用端添加额外的同步措施。Java语言中,大部分的线程安全类都是相对线程安全的,例如Vector、HashTable以及Collections的synchronizedCollection()方法包装的集合等。

线程兼容

线程兼容是指对象本身不是线程安全的,但是可以通过在调用端使用同步措施来保证对象在并发环境中可以安全的使用。Java中大部分类都是线程兼容的,如ArrayList、HashMap等等。

线程对立

线程对立指无论调用端是否采用了同步措施,都无法在多线程环境中并发使用代码。这种代码是有害的,应尽量避免。常见的线程对立操作有System.setIn()、System.setOut()和System.runFinalizersOnExit()等。

线程安全的实现方法

 线程安全的实现主要有以下几个方法:

互斥同步

通过互斥来实现同步,临界区、互斥量、信号量都是主要的互斥实现方法。在Java中最基本的互斥同步手段就是synchronized关键字,synchronized关键字通过编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的惨呼是来指明要锁定和解锁的对象。若在程序中为synchronized指明了对象参数,那就是这个对象的reference,若没有指明,则根据synchronized修饰的是实例方法或类方法,来获取对应的对象或Class对象来作为锁对象。

在虚拟机规范中要求,在执行monitorernter指令时,首先要尝试获取对象的锁。如若此对象没被锁定或当期线程已经拥有了此对象的锁,则把锁的计数器加1,响应的在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就会被释放。若获取锁失败,那么当前线程就要进入阻塞状态,直到对象锁被另外一个线程释放为止。

有两点需要注意的是:

  • synchronized同步快对于同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
  • 同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

除了synchronized之外,还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步。用法很相似,只是代码写法上有区别,ReentrantLock表现为API层面的互斥锁(lock()和unlock()方法配合try/finally()语句块来完成),synchronized表现为原生语法层面的互斥锁。不过ReentrantLock比synchronized增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁,以及锁可以绑定多个条件。

  • 等待可中断是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
  • 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;非公平锁,在释放时任何一个等待线程都有机会获得锁。synchronized是非公平锁,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
  • 锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外的添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。

非阻塞同步

互斥同步最主要的问题就是进行现场阻塞和唤醒锁带来的性能问题,因此这种同步也称为阻塞同步(Block Synchronization)。从处理问题的方式上来说,互斥同步属于一种悲观的并发策略,那么相对而言的就有了另一种基于冲突检测的乐观并发策略,通俗的解释就是先执行操作,如果没有其他线程争用共享数据,那操作就成功了;如果有线程争用共享数据,那就再采取其他补偿措施(常见的补偿措施就是不断重试,直到成功为止),这种乐观的并发策略不需要把线程挂起,因此也被称为非阻塞同步(Non-Block Synchronization)

在进行操作和冲突检测时,需要保证这两个步骤的原子性,这个时候如果靠同步互斥,那就也成悲观并发了,所以只能靠硬件来完成这个保证,硬件保证一个从语义上开起来需要多次操作的行为只通过一条处理器指令就能完成,此类指令常用的有:

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

无同步方案

可重入代码

如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。这个方法就是可重入代码,在这段代码可以在执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。

线程本地存储

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。例如大部分的消息队列的架构模式(生产者-消费者)都符合这个特点。

 

 

 

 

 

 

 

posted @ 2018-06-02 19:50  纪莫  阅读(776)  评论(0编辑  收藏  举报