如何创建线程?如何保证线程安全?

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何创建线程?如何保证线程安全?相关的知识,希望对你有一定的参考价值。

线程安全等级

之前的博客中已有所提及“线程安全”问题,一般我们常说某某类是线程安全的,某某是非线程安全的。其实线程安全并不是一个“非黑即白”单项选择题。按照“线程安全”的安全程度由强到弱来排序,我们可以将java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1、不可变

在java语言中,不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。如final关键字修饰的数据不可修改,可靠性最高。

2、绝对线程安全

绝对的线程安全完全满足Brian GoetZ给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的代价。

3、相对线程安全

相对线程安全就是我们通常意义上所讲的一个类是“线程安全”的。

它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在java语言中,大部分的线程安全类都属于相对线程安全的,例如Vector、HashTable、Collections的synchronizedCollection()方法保证的集合。

4、线程兼容

线程兼容就是我们通常意义上所讲的一个类不是线程安全的。

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境下可以安全地使用。Java API中大部分的类都是属于线程兼容的。如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。

5、线程对立

线程对立是指无论调用端是否采取了同步错误,都无法在多线程环境中并发使用的代码。由于java语言天生就具有多线程特性,线程对立这种排斥多线程的代码是很少出现的。

一个线程对立的例子是Thread类的supend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都有死锁风险。正因此如此,这两个方法已经被废弃啦。

二、线程安全的实现方法

保证线程安全以是否需要同步手段分类,分为同步方案和无需同步方案。

1、互斥同步

互斥同步是最常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

在java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码质量,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。

此外,ReentrantLock也是通过互斥来实现同步。在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性。

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确地同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁。

2、非阻塞同步

随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施。(最常见的补偿错误就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

非阻塞的实现CAS(compareandswap):CAS指令需要有3个操作数,分别是内存地址(在java中理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,CAS指令指令时,当且仅当V处的值符合旧预期值A时,处理器用B更新V处的值,否则它就不执行更新,但是无论是否更新了V处的值,都会返回V的旧值,上述的处理过程是一个原子操作。

CAS缺点:

ABA问题:因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。在变量前面追加版本号,每次变量更新的时候把版本号加一,那么A-B-A就变成了1A-2B-3C。JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

3、无需同步方案

要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步操作去保证正确性,因此会有一些代码天生就是线程安全的。

1)可重入代码

可重入代码(ReentrantCode)也称为纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。

可重入代码的特点是不依赖存储在堆上的数据和公用的系统资源、用到的状态量都是由参数中传入、不调用 非可重入的方法等。

(类比:synchronized拥有锁重入的功能,也就是在使用synchronized时,当一个线程得到一个对象锁后,再次请求此对象锁时时可以再次得到该对象的锁)

2)线程本地存储

如果一段代码中所需的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内。这样无需同步也能保证线程之间不出现数据的争用问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典的Web交互模型中的“一个请求对应一个服务器线程(Thread-per-Request)”的处理方式,这种处理方式的广泛应用使得很多Web服务器应用都可以使用线程本地存储来解决线程安全问题。
参考技术A 上一篇讨论了如何解决线程安全的问题,今天总结如何设计一个线程安全的类;
创建线程安全类的关注点
一个类要想线程安全,除了上一篇文章通过外部解决方式外,还可以通过合理的设计类的内部来解决,使类本身就线程安全,那么要怎么才能使类是线程安全的呢?
类不是线程安全的原因主要就是它包含了一些属性,这些属性是这个类实例对象的变量,这些变量影响着对象状态,由于对这些属性的访问在多线程情况下出现一些不安全使得对象状态并不符合预期导致类的不安全,所以设计线程安全类大概方向就是保证这些影响对象状态的变量,在Java并发编程实战中的总结如下:
找出构成对象状态的所有变量;
找出约束状态变量的不变性条件;
建立对象状态的并发访问管理策略;
这里先解释下不变性条件,在一些类的它的一些变量的变化是有一定规则的,比如类中定义一个属性表示苹果卖出了多少斤,卖出就增加,退货就减少,但是它肯定不会是一个负值,不为负值这就是这个变量的不变性条件。在比如类中定义了最大值与最小值,那么在这两个变量只有有一个不变性条件就是最大值要大于等于最小值。
简单解释了不变性条件再来理解下上面3条:可以把这3条分成3个步骤,首先是找出构成对象状态的所有变量,第二步是找出变量的约束条件,最后是上面两步找出的变量进行并发访问控制保证不变性条件的约束,对比较独立的属性直接进行并发访问,但是对有关联的那就必须要更多的机制保证这个约束。
所以我们可以把设计线程安全的类步骤分成以下三块:
1、找出所有需要同步的属性,保证不可变条件和后验条件(方法执行后必须为真的条件),比如上面举的卖出苹果的重量,比如最大值与最小值关系;
2、保证一些依赖一些状态的操作正确执行(先验条件方法执行前必须为真的条件),比如单例模式中实体为null的时候才初始化;
3、一定要控制这些属性的所有权,基础类型的属性可能比较好控制,有些是引用类型但又要保证线程安全的,那就要严格控制所有权,否则有可能其他线程拿到这个引用进行修改这个对象的内容,造成线程不安全;
已有对象如何保证线程安全
我们可以设计出线程安全的类,但是有可能有些对象已经存在,然后它并不是线程安全,现在却需要保证它的线程安全,那该如何做呢?
通过监视器模式实现,把对象封装到一个新对象里面,所有对这个对象的访问都通过新对象的方法访问,然后保证新对象的方法是线程安全的就行了。
并不一定需要自己实现线程安全
但是有时候我们并不是一定要设计成线程安全的类,如果已经存在一些线程安全的类可以保证我们需要的线程安全,还是要尽量用现有的,比如上面提到过苹果卖出的重量,就可以利用AtomicInteger来保证安全,再比如一些缓存也可以用ConcurrentMap来保证线程安全,把我们需要保证的线程安全委托给一些线程安全的类。
但是委托并不是一定有用的,比如前面的最大、最小值例子,如果定义成现AtomicInteger也无法保证它们的不可变条件的约束!这种可能就只能加锁了,但是如果他们两个并没有不可变条件约束是两个无关的共享变量,还是可以把多个无关的状态变量委托给线程安全的类。
如何扩展线程安全类
那么如果一个线程安全的类功能不满足我们的需求,需要扩展一些功能,可是又不能修改这个类,那么就必须要对这个类进行扩展,扩展分两种方法继承、客户端扩展。继承实现比较简单,继承的类只要在保证新增的方法是线程安全的,那么它整个都是线程安全的,不过客户端扩展可能情况复杂一点,一个错误的例子如下图:
新增的方法虽然加了锁,可是它是加在ListExpand这个类的对象上,而list方法里面的锁则是在list这个对象上,所有整体并不是线程安全的。是应该在方法内部然后锁list这个对象!
还可以把一些线程不安全的类通过继承然后重写方法实现线程安全,不过这和前面提到的通过监视器模式实现比较相似。
总结
从如何实现线程安全的类到线程不安全类如何保证线程安全,然后是如何利用已有线程安全类实现线程安全,当线程安全类不满足需求时又该如何扩展,这四个方面都进行了梳理。
Java程序员日常学习笔记,如理解有误欢迎各位交流讨论!

为什么会有多线程?什么是线程安全?如何保证线程安全?


本文将会回答这几个问题:

  1. 为什么会有多线程?

  2. 什么是线程安全?

  3. 怎么样保证线程安全?

为什么会有多线程

什么是线程安全

在谈什么是线程安全的问题之前,先给大家举一个线程不安全的例子,直接上代码

public class Test {
    private static int count;
    private static class Thread1 extends Thread {
        public void run() {
            for (int i = 0; i < 1000; i++) {
                count ++;
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread1  t1 = new Thread1();
        Thread1  t2 = new Thread1();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

这段代码实现的逻辑很简单,首先定义了一个int型的count变量,然后开启了两个线程,每个线程执行1000次循环,循环中对count进行加1操作。等待两个线程都执行完成后,打印count的值。那么这段代码的输出结果是多少呢?可能很多人会说是2000。但是程序运行后却发现结果大概率不是2000,而是一个比2000略小的数,比如1998这样,而且每次运行的结果可能都不相同。
那么这是为什么呢?这就是线程不安全。线程安全是指在多线程环境下,程序可以始终执行正确的行为,符合预期的逻辑。比如我们刚刚的程序,共两个线程,每个线程对count变量累加1000次,预期的逻辑是count被累加了2000次,而代码执行的结果却不是2000,所以它是线程不安全的。
为什么是不安全的呢?因为count++的指令在实际执行的过程中不是原子性的,而是要分为读、改、写三步来进行;即先从内存中读出count的值,然后执行+1操作,再将结果写回内存中,如下图所示。

线程交替执行

这就是线程在计算机中真实的执行过程,看起来好像没问题啊,别急,再看一张图
不安全的线程交替执行

看出来问题了么?上图中线程1执行了两次自加操作,而线程2执行了一次自加操作,但是count却从6变成了8,只加了2.我们看一下为什么会出现这种情况。当线程1读取count的值为6完成后,此时切换到了线程2执行,线程2同样读取到了count的值为6,而后进行改和写操作,count的值变为了7;此时线程又切回了线程1,但是线程1中count的值依然是线程2修改前的6,这就是问题所在!!!即线程2修改了count的值,但是这种修改对线程1不可见,导致了程序出现了线程不安全的问题,没有符合我们预期的逻辑。
相信大家现在已经对线程不安全已经有了一定的认识了。现在我们总结一下导致线程不安全的原因,主要有三点:
  • 原子性:一个或者多个操作在 CPU 执行的过程中被中断

  • 可见性:一个线程对共享变量的修改,另外一个线程不能立刻看到

  • 有序性:程序执行的顺序没有按照代码的先后顺序执行

前两点前面已经举例了,现在在解释一下第三点。为什么程序执行的顺序会和代码的执行顺序不一致呢?java平台包括两种编译器:静态编译器(javac)和动态编译器(jit:just in time)。静态编译器是将.java文件编译成.class文件(二进制文件),之后便可以解释执行。动态编译器是将.class文件编译成机器码,之后再由jvm运行。问题一般会出现在动态编译器上,因为动态编译器为了程序的整体性能会对指令进行重排序,虽然重排序可以提升程序的性能,但是重排序之后会导致源代码中指定的内存访问顺序与实际的执行顺序不一样,就会出现线程不安全的问题。

如何保证线程安全

下面简单谈谈针对以上的三个问题,java程序如何保证线程安全呢?
针对问题1:JDK里面提供了很多atomic类,比如AtomicInteger, AtomicLong, AtomicBoolean等等,这些类本身可以通过CAS来保证操作的原子性;另外Java也提供了各种锁机制,来保证锁内的代码块在同一时刻只能有一个线程执行,比如刚刚的例子我们就可以加锁,如下:

synchronized (Test.class){
    count ++;
}

这样,就能够保证一个线程在多count值进行读、改、写操作时,其他线程不可对count进行操作,从而保证了线程的安全性。
针对问题2:同样可以通过synchronized关键字加锁来解决。与此同时,java还提供了一种轻量级的锁,即volatile关键字,要优于synchronized的性能,同样可以保证修改对其他线程的可见性。volatile一般用于对变量的写操作不依赖于当前值的场景中,比如状态标记量等。
 针对问题3:可以通过synchronized关键字定义同步代码块或者同步方法保障有序性,另外也可以通过Lock接口保障有序性。
怎么样?现在是不是对线程安全有了更加深入的理解了呢?

以上是关于如何创建线程?如何保证线程安全?的主要内容,如果未能解决你的问题,请参考以下文章

如何创建线程?如何保证线程安全?

如何创建线程?如何保证线程安全?

如何创建线程?如何保证线程安全?

如何保证ArrayList线程安全

如何保证集合是线程安全的?

如何保证线程安全?