• 并发编程(八)并发安全


    什么是线程安全

    当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

    线程封闭

    把对象封装到一个线程里,只有这一个线程能看到此对象。那么这个对象就算不是线程安全的也不会出现任何安全问题。

    实现方法:

    ad-hoc线程封闭

    这是完全靠实现者控制的线程封闭,他的线程封闭完全靠实现者实现。Ad-hoc线程封闭非常脆弱,应该尽量避免使用

    栈封闭

    简单的说就是局部变量。多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到线程栈中。所以局部变量是不被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。

    无状态的类

    没有任何成员变量的类,就叫无状态的类,这种类一定是线程安全的。

    public class StatelessClass {
        public int service(int a,int b){
            return a+b;
        }
    
        public void serviceUser(UserVo user){//使用对象的情况下也是线程安全的,对于staelessClass来说,它并不持有UserVo对象实例,自己没有任何问题。
            //do sth user
        }
    }

    让类不可变

    让状态不可变,两种方式:

    1,加final关键字,对于一个类,所有的成员变量应该是私有的,同样的只要有可能,所有的成员变量应该加上final关键字,但是加上final,要注意如果成员变量又是一个对象时,这个对象所对应的类也要是不可变,才能保证整个类是不可变的。

    2,根本就不提供任何可供修改成员变量的地方,同时成员变量也不作为方法的返回值。

    public class ImmutableClassToo {
        private List<Integer> list = new ArrayList<>(3);
        public ImmutableClassToo() {
            list.add(1);
            list.add(2);
            list.add(3);
        }
        public boolean isContain(int i){
            return list.contains(i);
        }
    }

    但是要注意,一旦类的成员变量中有对象,上述的final关键字保证不可变并不能保证类的安全性,为何?因为在多线程下,虽然对象的引用不可变,但是对象在堆上的实例是有可能被多个线程同时修改的,没有正确处理的情况下,对象实例在堆中的数据是不可预知的。这就牵涉到了如何安全的发布对象这个问题。

    volatile

    并不能保证类的线程安全性,只能保证类的可见性,最适合一个线程写,多个线程读的情景

    加锁和CAS

    我们最常使用的保证线程安全的手段,使用synchronized关键字,使用显式锁,使用各种原子变量,修改数据时使用CAS机制等等。

    安全的发布

    类中持有的成员变量,如果是基本类型,发布出去,并没有关系,因为发布出去的其实是这个变量的一个副本。

    但是如果类中持有的成员变量是对象的引用,如果这个成员对象不是线程安全的,通过get等方法发布出去,会造成这个成员对象本身持有的数据在多线程下不正确的修改,从而造成整个类线程不安全的问题。

    public class UnSafePublish {
        private List<Integer> list = new ArrayList<>(3);
        
        public UnSafePublish() {
            list.add(1);
            list.add(2);
            list.add(3);
        }
        
        public List getList() {
            return list;
        }
    
        public static void main(String[] args) {
            UnSafePublish unSafePublish = new UnSafePublish();
            List<Integer> list = unSafePublish.getList();
            System.out.println(list);
            list.add(4);
            System.out.println(list);
            System.out.println(unSafePublish.getList());
        }
    }

    我们在发布这对象出去的时候,就应该用线程安全的方式包装这个对象。

     private List<Integer> list = Collections.synchronizedList(new ArrayList<Integer>(3));

    对于我们自己使用或者声明的类,JDK自然没有提供这种包装类的办法,但是我们可以仿造这种模式或者委托给线程安全的类,当然,对这种通过get等方法发布出去的对象,最根本的解决办法还是应该在实现上就考虑到线程安全问题。

    ThreadLocal

    ThreadLocal是实现线程封闭的最好方法。ThreadLocal内部维护了一个Map,Map的key是每个线程的名称,而Map的值就是我们要封闭的对象。每个线程中的对象都对应着Map中一个值,也就是ThreadLocal利用Map实现了对象的线程封闭。

    Servlet辨析

    不是线程安全的类,为什么我们平时没感觉到:

    1、在需求上,很少有共享的需求,2、接收到了请求,返回应答的时候,一般都是由一个线程来负责的。

    但是只要Servlet中有成员变量,一旦有多线程下的写,就很容易产生线程安全问题。

    Spring辨析

    spring进行bean初始化的时候,使用了synchronized加锁,IOC容器使用的HashMap容器而非ConcurrentHashMap,原因在于spring总beande 初始化仅进行一次的,后续多是读操作,所以并没有使用ConcurrentHashMap.

    /**
         * Obtain an object to expose from the given FactoryBean.
         * @param factory the FactoryBean instance
         * @param beanName the name of the bean
         * @param shouldPostProcess whether the bean is subject to post-processing
         * @return the object obtained from the FactoryBean
         * @throws BeanCreationException if FactoryBean object creation failed
         * @see org.springframework.beans.factory.FactoryBean#getObject()
         */
        protected Object getObjectFromFactoryBean(FactoryBean<?> factory, String beanName, boolean shouldPostProcess) {
            if (factory.isSingleton() && containsSingleton(beanName)) {
                synchronized (getSingletonMutex()) {
                    Object object = this.factoryBeanObjectCache.get(beanName);
                    if (object == null) {
                        object = doGetObjectFromFactoryBean(factory, beanName);
                        // Only post-process and store if not put there already during getObject() call above
                        // (e.g. because of circular reference processing triggered by custom getBean calls)
                        Object alreadyThere = this.factoryBeanObjectCache.get(beanName);
                        if (alreadyThere != null) {
                            object = alreadyThere;
                        }
                        else {
                            if (object != null && shouldPostProcess) {
                                try {
                                    object = postProcessObjectFromFactoryBean(object, beanName);
                                }
                                catch (Throwable ex) {
                                    throw new BeanCreationException(beanName,
                                            "Post-processing of FactoryBean's singleton object failed", ex);
                                }
                            }
                            this.factoryBeanObjectCache.put(beanName, (object != null ? object : NULL_OBJECT));
                        }
                    }
                    return (object != NULL_OBJECT ? object : null);
                }
            }
            else {
                Object object = doGetObjectFromFactoryBean(factory, beanName);
                if (object != null && shouldPostProcess) {
                    try {
                        object = postProcessObjectFromFactoryBean(object, beanName);
                    }
                    catch (Throwable ex) {
                        throw new BeanCreationException(beanName, "Post-processing of FactoryBean's object failed", ex);
                    }
                }
                return object;
            }
        }

    死锁

    概念

    是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。

    死锁是必然发生在多操作者(M>=2个)情况下,争夺多个资源(N>=2个,且N<=M)才会发生这种情况。很明显,单线程自然不会有死锁。同时,死锁还有一个重要的要求,争夺资源的顺序不对,如果争夺资源的顺序是一样的,也不会产生死锁。

    学术化定义

    死锁的发生必须具备以下四个必要条件。

    1)互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。

    2)请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。

    3)不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。

    4)环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

    理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生:打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。

    避免死锁常见的算法有有序资源分配法、银行家算法。

    现象、危害和解决办法

    数据库里多事务而且要同时操作多个表的情况下会产生死锁现象。所以数据库设计的时候就考虑到了检测死锁和从死锁中恢复的机制。比如oracle提供了检测和处理死锁的语句,而mysql也提供了“循环依赖检测的机制”。

    危害

    1、线程不工作了,但是整个程序还是活着的

    2、没有任何的异常信息可以供我们检查。

    3、一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对生产平台的程序来说,这是个很严重的问题。

    实际工作中的死锁

    时间不定,不是每次必现;一旦出现没有任何异常信息,只知道这个应用的所有业务越来越慢,最后停止服务,无法确定是哪个具体业务导致的问题;测试部门也无法复现,并发量不够。

    解决

    定位

    通过jps 查询应用的 id,再通过jstack id 查看应用的锁的持有情况

    修正

    关键是保证拿锁的顺序一致

    两种解决方式

    1、内部通过顺序比较,确定拿锁的顺序;

    2、采用尝试拿锁的机制。

    其他安全问题

    活锁

    两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。

    解决办法:每个线程休眠随机数,错开拿锁的时间。

    线程饥饿

    低优先级的线程,总是拿不到执行时间

    并发下的性能

    使用并发的目标是为了提高性能,引入多线程后,其实会引入额外的开销,如线程之间的协调、增加的上下文切换,线程的创建和销毁,线程的调度等等。过度的使用和不恰当的使用,会导致多线程程序甚至比单线程还要低。

    衡量应用的程序的性能:服务时间,延迟时间,吞吐量,可伸缩性等等,其中服务时间,延迟时间(多快),吞吐量(处理能力的指标,完成工作的多少)。多快和多少,完全独立,甚至是相互矛盾的。

    对服务器应用来说:多少(可伸缩性,吞吐量)这个方面比多快更受重视。

    我们做应用的时候:

    1、先保证程序正确,确实达不到要求的时候,再提高速度。(黄金原则)

    2、一定要以测试为基准。

    线程引入的开销

    上下文切换

    如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。

    切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。

    当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。

    上下文切换是计算密集型操作。也就是说,它需要相当可观的处理器时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于50~10000个时钟周期,也就是几微秒。

    UNIX系统的 vmstat命令能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由IO或竞争锁导致的阻塞引起的。

    内存同步

    同步操作的性能开销包括多个方面。在 synchronized和 volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏( Memory Barrier)。

    内存栅栏可以刷新缓存,使缓存无效刷新硬件的写缓冲,以及停止执行管道。

    内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。

    阻塞

    引起阻塞的原因:包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待等等。

    阻塞会导致线程挂起【挂起:挂起进程在操作系统中可以定义为暂时被淘汰出内存的进程,机器的资源是有限的,在资源不足的情况下,操作系统对在内存中的程序进行合理的安排,其中有的进程被暂时调离出内存,当条件允许的时候,会被操作系统再次调回内存,重新进入等待被执行的状态即就绪态,系统在超过一定的时间没有任何动作】。

    很明显这个操作至少包括两次额外的上下文切换,还有相关的操作系统级的操作等等。

    如何减少锁的竞争

    减少锁的粒度

    使用锁的时候,锁所保护的对象是多个,当这些多个对象其实是独立变化的时候,不如用多个锁来一一保护这些对象。但是如果有同时要持有多个锁的业务方法,要注意避免发生死锁

    缩小锁的范围

    对锁的持有实现快进快出,尽量缩短持由锁的的时间。将一些与锁无关的代码移出锁的范围,特别是一些耗时,可能阻塞的操作

    避免多余的锁

    两次加锁之间的语句非常简单,导致加锁的时间比执行这些语句还长,这个时候应该进行锁粗化—扩大锁的范围。

    锁分段

    ConcurrrentHashMap就是典型的锁分段。

    替换独占锁

    在业务允许的情况下:

    1、使用读写锁,

    2、用自旋CAS

    3、使用系统的并发容器

    线程安全的单例模式

    双重检查锁定

    /**
    * 懒汉式-双重检查
    */
    public class SingleDcl {
    private volatile static SingleDcl singleDcl;
    //私有化
    private SingleDcl(){
    }

    public static SingleDcl getInstance(){
    if (singleDcl == null){ //第一次检查,不加锁
    System.out.println(Thread.currentThread()+" is null");
    synchronized(SingleDcl.class){ //加锁
    if (singleDcl == null){ //第二次检查,加锁情况下
    System.out.println(Thread.currentThread()+" is null");
    //内存中分配空间 1
    //空间初始化 2
    //把这个空间的地址给我们的引用 3
    singleDcl = new SingleDcl();
    }
    }
    }
    return singleDcl;
    }
    }

    解决之道

    懒汉式

    类初始化模式,也叫延迟占位模式。在单例类的内部由一个私有静态内部类来持有这个单例类的实例。

    延迟占位模式还可以用在多线程下实例域的延迟赋值。

    饿汉式

    在声明的时候就new这个类的实例,因为在JVM中,对类的加载和类初始化,由虚拟机保证线程安全。

    或者使用枚举

    参考:http://enjoy.ke.qq.com

  • 相关阅读:
    Table的基本操作
    MySQL数据库基本操作
    jmeter中服务器返回的cookies的查看
    jemeter的乱码问题
    cucumber的报告
    Cucumber的依赖
    idea里maven执行插件pom文件依赖设置
    Tomcat和jenkins的安装
    maven配置
    Ajax必知必会
  • 原文地址:https://www.cnblogs.com/cangshublogs/p/10947036.html
Copyright © 2020-2023  润新知