有状态和无状态_线程安全问题简单讨论

关于线程问题的讨论

    1.线程和进程的概念

    2.synchronized、Atomic和volatile关键字与线程安全相关性

    3.有状态和无状态

一、先讨论线程和进程的概念

在理解进程和线程概念之前首选要对并发有一定的感性认识,如果服务器同一时间内只能服务于一个客户端,其他客户端都再那里傻等的话,可见其性能的低下估计会被客户骂出翔来,因此并发编程应运而生,并发是网络编程中必须考虑的问题。实现并发的方式有多种:比如多进程、多线程、IO多路复用。

多进程:

进程是资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例。程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。

Linux系统函数fork()可以在父进程中创建一个子进程,这样的话,在一个进程接到来自客户端新的请求时就可以复制出一个子进程让其来处理,父进程只需负责监控请求的到来,然后创建子进程让其去处理,这样就能做到并发处理。

# -*- coding:utf-8 -*-

import os

print('当前进程:%s 启动中 ....' % os.getpid())

pid = os.fork()

if pid == 0:

    print('子进程:%s,父进程是:%s' % (os.getpid(), os.getppid()))

else:

    print('进程:%s 创建了子进程:%s' % (os.getpid(),pid ))

输出结果:

当前进程:27223 启动中 ....

进程:27223 创建了子进程:27224

子进程:27224,父进程是:27223

fork函数会返回两次结果,因为操作系统会把当前进程的数据复制一遍,然后程序就分两个进程继续运行后面的代码,fork分别在父进程和子进程中返回,在子进程返回的值pid永远是0,在父进程返回的是子进程的进程id。

多线程:

线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位,一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的堆栈和局部变量线程由CPU独立调度执行,在多CPU环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,每个请求分配一个线程来处理。

线程和进程各自有什么区别和优劣呢?

  • 进程是资源分配的最小单位,线程是程序执行的最小单位
  • 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
  • 线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点。
  • 但是多进程程序更健壮多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。

 

二、synchronized、Atomic和volatile关键字与线程安全问题

 

1).synchronized

java关键字,代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。它包括两种用法:synchronized 方法和 synchronized 块。

 

  • 当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
  • 然而,另一个线程仍然可以访问该object中的非synchronized(this)同步代码块
  • 尤其关键的是,当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对object中所有其它synchronized(this)同步代码块的访问将被阻塞
  • 第三个例子同样适用其它同步代码块,它就获得了这个object的对象锁。结果,其它线程对该object对象所有同步代码部分的访问都被暂时阻塞。
  • 以上规则对其它对象锁同样适用。

 

2).Atomic

原子操作多进程线程)访问共享资源时,能够确保所有其他的进程(线程)都不在同一时间内访问相同的资源。原子操作(atomic operation)是不需要synchronized,这是Java多线程编程的老生常谈了。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。通常所说的原子操作包括对非long和double型的primitive进行赋值,以及返回这两者之外的primitive。之所以要把它们排除在外是因为它们都比较大,而JVM的设计规范又没有要求读操作和赋值操作必须是原子操作(JVM可以试着去这么作,但并不保证)。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

比较并交换(compare and swap, CAS):是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。 该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值

用CAS操作实现安全的自增

分析incrementAndGet() 方法的源码:

 public final int incrementAndGet() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return next;
        }
 }
 public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
 }

大学操作系统课程中我们学过独占锁和乐观锁的概念。独占锁就是线程获取锁后其他的线程都需要挂起,直到持有独占锁的线程释放锁;乐观锁是先假定没有冲突直接进行操作,如果因为有冲突而失败就重试,直到操作成功。其中乐观锁用到的机制就是CAS,Compare and Swap。AtomicInteger 中的CAS操作就是compareAndSet(),其作用是每次从内存中根据内存偏移量(valueOffset)取出数据,将取出的值跟expect 比较,如果数据一致就把内存中的值改为update这样使用CAS就保证了原子操作。其余几个方法的原理跟这个相同,在此不再过多的解释。没看AtomicInteger 源码之前,我认为其内部是用synchronized 来实现的原子操作。查阅资料后发现synchronized 会影响性能,因为Java中的synchronized 锁是独占锁,虽然可以实现原子操作,但是这种实现方式的并发性能很差

3).volatile

volatile相当于synchronized的弱实现,也就是说volatile实现了类似synchronized的语义却又没有锁机制。它确保对volatile字段的更新以可预见的方式告知其他的线程。

volatile包含以下语义:

  • Java 存储模型不会对valatile指令的操作进行重排序:这个保证对volatile变量的操作时按照指令的出现顺序执行的。
  • volatile变量不会被缓存在寄存器中(只有拥有线程可见)或者其他对CPU不可见的地方,每次总是从主存中读取volatile变量的结果。也就是说对于volatile变量的修改,其它线程总是可见的,并且不是使用自己线程栈内部的变量。也就是在happens-before法则中,对一个valatile变量的写操作后,其后的任何读操作理解可见此写操作的结果。

简而言之volatile 的作用是当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。在分析AtomicInteger 源码时,我们了解到这里就足够了。

4).线程安全的问题

     方式一:使用同步锁synchronized

     方式二:使用原子操作Atomic

public class AtomicIntegerTest {
    private static final int THREADS_NUMBER = 100;
    public static int count = 0;
    public static void increase() {
        count++;
    }
 
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_NUMBER];
        for (int i = 0; i < 100; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 1000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
		
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(count);
    }
}

测试结果:数值总是小于100000的一个数,而且可能不一样

如果换成volatile修饰count并不能改变线程不安全的问题

public static volatile int count = 0;

 

如果切换成AotmicInteger原子类型来操作

import java.util.concurrent.atomic.AtomicInteger; 

public class AtomicIntegerTest {    
	private static final int THREADS_NUMBER = 100;   
	public static AtomicInteger count = new AtomicInteger(0);    
	
	public static void increase() {       
		count.incrementAndGet();    
	}    
	
	public static void main(String[] args) {        
		Thread[] threads = new Thread[THREADS_NUMBER];        
		
		for (int i = 0; i < 100; i++) {            
			threads[i] = new Thread(new Runnable() {                
				@Override                
				public void run() {                    
					for (int i = 0; i < 1000; i++) {                        
						increase();                    
					}                
				}            
			});           
			threads[i].start();        
		}         
		
		while (Thread.activeCount() > 1) {            
			Thread.yield();        
		}      
		
		System.out.println(count);    
	}
}

测试结果:incrementAndGet方法完美的解决了线程安全的问题

简要分析:

    a.非阻塞同步:阻塞同步和非阻塞同步都是实现线程安全的两个保障手段,非阻塞同步对于阻塞同步而言主要解决了阻塞同步中线程阻塞和唤醒带来的性能问题,那什么叫做非阻塞同步呢?在并发环境下,某个线程对共享变量先进行操作,如果没有其他线程争用共享数据那操作就成功;如果存在数据的争用冲突,那就才去补偿措施,比如不断的重试机制,直到成功为止,因为这种乐观的并发策略不需要把线程挂起,也就把这种同步操作称为非阻塞同步(操作和冲突检测具备原子性)。在硬件指令集的发展驱动下,使得 "操作和冲突检测" 这种看起来需要多次操作的行为只需要一条处理器指令便可以完成。

        b.线程操作中的CAS:CAS有3个操作数--内存值V、旧的预期值A、要修改的新值B

当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值(A和内存值V相同时,将内存值V修改为B),而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试(或者什么都不做)我们画张图来理解一下:

 

 

我们可以发现CAS有两种情况:

  • 如果内存值V和我们的预期值A相等,则将内存值修改为B,操作成功!

  • 如果内存值V和我们的预期值A不相等,一般也有两种情况:

    • 重试(自旋)

    • 什么都不做

我们再继续往下看,如果内存值V和我们的预期值A不相等时,应该什么时候重试,什么时候什么都不做。

2.4.1CAS失败重试(自旋)

比如说,我上面用了100个线程,对count值进行加1。我们都知道:如果在线程安全的情况下,这个count值最终的结果一定是为100的。那就意味着:每个线程都会对这个count值实质地进行加1

我继续画张图来说明一下CAS是如何重试(循环再试)的:

 

CAS循环重试

上面图只模拟出两个线程的情况,但足够说明问题了。

 

2.4.2CAS失败什么都不做

上面是每个线程都要为count值加1,但我们也可以有这种情况:将count值设置为5

我也来画个图说明一下:

 

CAS失败什么都不做

理解CAS的核心就是:CAS是原子性的,虽然你可能看到比较后再修改(compare and swap)觉得会有两个操作,但终究是原子性的!

 

2.4.3CAS存在的ABA问题

 

使用CAS有个缺点就是ABA的问题,什么是ABA问题呢?首先我用文字描述一下:

  • 现在我有一个变量count=10,现在有三个线程,分别为A、B、C

  • 线程A和线程C同时读到count变量,所以线程A和线程C的内存值和预期值都为10

  • 此时线程A使用CAS将count值修改成100

  • 修改完后,就在这时,线程B进来了,读取得到count的值为100(内存值和预期值都是100),将count值修改成10

  • 线程C拿到执行权,发现内存值是10,预期值也是10,将count值修改成11

上面的操作都可以正常执行完的,这样会发生什么问题呢??线程C无法得知线程A和线程B修改过的count值,这样是有风险的。

下面我再画个图来说明一下ABA的问题(以链表为例):

 

CAS ABA的问题讲解

要解决ABA的问题,我们可以使用JDK给我们提供的AtomicStampedReference和AtomicMarkableReference类。

AtomicStampedReference:

An {@code AtomicStampedReference} maintains an object referencealong with an integer "stamp", that can be updated atomically.

简单来说就是在给为这个对象提供了一个版本,并且这个版本如果被修改了,是自动更新的。

原理大概就是:维护了一个Pair对象,Pair对象存储我们的对象引用和一个stamp值。每次CAS比较的是两个Pair对象

    // Pair对象
    private static class Pair<T> {
        final T reference;
        final int stamp;
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

    private volatile Pair<V> pair;

    // 比较的是Pari对象
    public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }

因为多了一个版本号比较,所以就不会存在ABA的问题了。

来源:https://mp.weixin.qq.com/s?__biz=MzIxNjA5MTM2MA==&mid=2652435244&idx=1&sn=68f35986aae5d48793587d3402b7f341&chksm=8c620ca3bb1585b5a3c913a7a8420cd342080d3a49569e5e939f022416e21d20e7408b8a879b&mpshare=1&scene=24&srcid=1229LVVBTneHZVEZliOa3HQn&key=358b8417260af1faed513116c7dc929d70065440ed5b60396dc2adc75cfb454ae1b36be304b4a15811ab6e40b2581f76c706c999e5e1ac2c4a73514c89b34fa31044f09095fad246feec8271a59338db&ascene=14&uin=Mjc4OTA2NzgwMw%3D%3D&devicetype=Windows+10&version=62060526&lang=zh_CN&pass_ticket=czP53Ujm5IOE4%2F1lC5rCyyMXpw9jPk5lLFdELTYcsqNrJQlwvyIeLxiEX5a8ACJE&winzoom=1

 

三、再讨论有状态和无状态

在程序设计中,状态的概念是非常抽象的,要给出一个所有人都能接受的定义真的太难了,所以我只能根据我自己的理解尝试一下。我理解的状态是这样的:在两次或多次不同的进程(或线程)调用间有目的地引用了同一组数据,这组数据就称为状态,这样的调用就叫有状态调用,相反就是无状态调用。从这个定义中我们至少可以得出以下三点:

  • 状态是一组数据。数据有可变与不可变之分,对其访问的方法是不一样的。
  • 不同的进程或线程间调用。可以是同一个程序的不同的线程间调用,也可以是不同进程间,甚至是不同的机器间。要满足上面的三种情景,被访问的状态数据必须是被共享的,而且在本次访问中对状态的修改,在下次的访问中是可见的。
  • 有目的地引用同一组数据。所谓有目的地引用,言外之意是我们在程序设计时是故意这么做的。所以,程序有没有状态是由程序设计人员决定的

状态存在于程序设计的各个方面,即存在于类的对象中,也存在于各种同步和异步的通信中。我们有目的地设计有状态的程序,其实是为了满足某种需求,但盲目地使用有状态的程序却会带来性能以及拓展性的问题。无状态的程序始终会在性能和拓展性方面优于有状态的程序,所以在设计有状态的程序时,我们需要兼顾性能和拓展性。下面我们从几个场景来分析状态的使用。

1).对象的状态

具体到类的对象上,状态其实是一组全局变量(或叫对象的变量),由于局部变量在方法体运行完成后就可能被Java虚拟机回收了,所以局部变量天生就是无状态的。类的静态变量永远都是有状态的,因为类变量的设计是为了在此类的所有对象中共享数据。

  我们都知道,对象的创建和初始化是非常耗时间和资源的,所以在设计类的时候我们会考虑对象是如何创建以及创建多少的问题。大致分为三种方案:

  • 单例,即一个类只有创建一个对象,所有线程共用这个对象。这样可以节省大量的系统开销,便于在系统内共享数据,也便于满足某种特殊的需求,比如Java Web程序的ServletContext对象,容器只创建一个这种对象,可以将程序级别的共享数据放入其中。为了实现单例,我们通常会使用单例模式来控制单例对象的创建和初始化。单例中对外开放写功能的全局变量都是有状态的,在不同线程中使用时必须考虑其线程安全的问题。
  • 对象池,即初始化一定数量的对象,并将其放入一个有界容器中,容器的最大容量既是对象的最大数量,当需要新的对象时从池中取出一空闲对象,如果没有空闲的,在未达容器最大容量前会新建一个新的对象,在达到容器最大容量后,只能等待空闲对象的出现,最后将使用完的对象放回池中。为了保证对象的线程安全性,对象池中的对象必须是无状态的,或者状态为不可改变。Tomcat对Servlet的管理就是采用对象池的做法,这样可以避免频繁的对象创建,可以显著的提高系统性能和容量,加快对客服端的响应速度。
  • 按需创建对象,即需要时创建,用完后抛弃。这个方案有以下两种情景,
  1. 单线程情景,不存在并发修改或读取同一对象状态的情况,所以我们无需考虑状态 的问题。
  2. 多线程情景,不同线程会并发的修改或读取同一个对象的状态,要使得对象是线程 安全的,需要采用同步机制来协同对对象可变状态的访问。

 

2).多线程中的状态

在多线程的情形中,无状态的对象跟单线程中的对象没有任何区别,因为无状态的对象不共享数据,也就没有线程安全的问题,所以在这里我们只讨论有状态的对象。但状态的可变与不可变是有本质区别的,不可变的状态是线程安全的可变的状态是线程不安全的,所以在访问可变状态时我们需要借助某种机制来同步状态,以达到安全的访问

  要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问。“共享”意味着状态可以由多个线程同时访问,而“可变”则意味着状态的值在其生命周期内可以发生变化。当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中的主要同步机制是关键字synchronized,它提供了一种独占锁方式,但“同步”这个术语还包括volatile类型的变量,显示锁以及原子变量

  下面所列为在设计并发程序时,对象状态设计及同步的技巧,细节请参考书籍《Java并发编程实战》:

  • 所有的并发问题都可以归结为如何协调对并发状态变量的访问。可变状态越少就越容易确保线程安全性。
  • 尽量将状态变量声明为final类型,除非他们是可变的。
  • 不可变对象一定是线程安全的。不可变对象能极大地降低并发编程的复杂性。他们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制。
  • 封装有助于管理复杂性。在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做?将数据封装在对象中,更容易维护不变性条件:将同步机制封装在对象中,更易于遵循同步策略
  • 用锁来保护每个可变状态变量
  • 保护同一个不变性条件中的所有状态变量时,要使用同一个锁
  • 在执行复合操作间,要持有锁
  • 如果从多个线程中访问同一个可变状态变量时没有同步机制,那么程序会出现问题
  • 不要故作聪明地推断出不需要使用同步。
  • 在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的
  • 将同步策略文档化。

3).分布式系统的状态

在分布式系统中,有状态的数据是非常昂贵的,这需要消耗大量的资源,而且很难拓展。下面我们用Java Web分布式集群中Session同步的设计来探讨分布式集群系统中状态数据的设计和实现。

  我们都知道Java Web中的Session是一个有状态的对象,可以用来在同一个用户的多次访问间共享数据,比如记录用户的登录状态,或者在多个页面间共享数据。对于高访问量、高并发的网站或Web程序来说,目前比较常见的解决方案应该是利用负载均衡进行server集群,例如比较流行的nginx+memcache+tomcat。集群之后我们会有多个Tomcat,用户在访问我们的网站时有可能第一次请求分发到tomcat1下,而第二次请求却分发到了tomcat2,有过web开发经验的朋友都会知道如果这时两个tomcat中的session不一致会导致怎样的后果,可以想象这个用户会收到未登录的信息,或者部分数据的丢失,因此,在Web集群环境下,我们需要解决多个Tomcat之间Session同步的问题。目前比较流行的解决方案有以下两个:

  • 所有Tomcat共享同一份Session,即每个Tomcat中都保存了整个网站中所有访问用户的Session。为了达到这点,需要将Session同步到所有的Tomcat中,我们可以用Tomcat自带的同步插件来实现。如何实现请参考Tomcat官方文档。
  • Tomcat中并不在Session中保存任何用户数据数据保存在独立的缓存服务器或DB中,每次用户请求到来时,从缓存服务器或DB中取出用户数据,并重构Session。在此种方案中虽然Session同样具有状态,但并不保存任何用户数据,所以从这个角度来讲Session并无状态,不管是哪个Tomcat中的Session来服务用户,都不会丢失数据,Tomcat间无需同步Session

上面两种方案都可以解决Tomcat分布式集群Session同步的问题。第一种方案是利用Session的有状态性来保存用户数据,但需要在所有节点中同步保证一份完整的Session,当访问量大时,可能会耗尽tomcat的内存资源,同时Session的同步也可能导致内部网络资源的紧张,最终导致用户响应时间变长甚至系统崩溃。而第二种方案却是避免了Session的有状态性,非常优雅地解决了第一种方案中的问题,不但可以响应更大的访问量,而且具有非常好的拓展性,当系统无法响应更多的访问量时,可以简单地加入更多Tomcat来解决。从上面的分析可知,无状态的数据比有状态的数据具有更好的性能和拓展性,所以在程序设计时我们应该尽量避免设计有状态的程序

 

注:内容都为摘录整理,只为更好学习!!!

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值