为什么要使用多线程

在硬件条件不断进步的今天,我们现在常用的电脑已经不是简单的单核CPU,而是4核、8核、甚至更多。

而如果不使用多线程技术的话,一般我们就只用一个CPU来处理程序上的计算问题,复杂且庞大的计算量全部压在一个CPU上,其它CPU只负责划水,那么这无法物尽其用。

当然我们也不是说可怜这一个CPU,而是真实的环境下,使用多核CPU以及超线程技术可以实现并行,这意味着我们可以在单位时间内处理更多的请求,提升吞吐量。

多线程带来的安全性问题

一个技术的提出,有好处当然也有弊端。虽然多线程技术可以让吞吐量提升,但是多线程技术在运用中会带来许多安全性问题,一个比较常见的就是对共享数据的访问

我们知道,每个线程单独独立,因为他们拥有自己的内存空间,彼此不干扰,那么当两个没有任何关系的线程去对同时一个值进行修改的时候,谁先谁后?谁又能够保证谁拿到的是修改后的值呢?如果修改了,其它线程是否可以及时拿到更新后的值呢?

这里是不是有点像数据库中,脏读的概念呢?

打开关闭CPU超线程 centos_jvm


两个线程同时对变量i进行++操作的结果,是否是我们所期待的3呢?

对于线程安全性,本质上是管理对于数据状态的访问,同时数据状态需要满足两个条件。

  • 共享的,这个数据能够被多个线程访问到
  • 可变的,这个数据在的生命周期可以被改变

那如果一个对象,不需要我们使用其他一些同步手段,或者一些协助的请下,依旧可以达到预期结果的话,就意味着这个对象是线程安全的。

对于上面的例子而言,如果两个线程同时去修改i的值,并且最后i的结果是3的话,那么说明这个数据是线程安全的,如果结果不为3的话,说明这个数据没有达到我们的预期结果,意味着这个数据不是线程安全,我们就需要一些同步的手段,来促使他达到我们预期的结果3

public class ThreadDemo {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {

        for(int i =0;i<1000;i++){

            new Thread(()->{


                try {
                    Thread.sleep(2);//让他改变的时候多延迟一下
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                count ++; // 1000 个线程将会对这个count进行数值修改
            }).start();

        }
        Thread.sleep(3000);
        System.out.println(count);
    }

}

打开关闭CPU超线程 centos_数据_02

以上代码多测试几遍,你会发现有些结果会与1000有些差别,倘若这个问题,出现在线上的话,例如汇总分析的数值总是有差池,你的领导务必会找你的麻烦。

那么此时,我们可以认为这个count线程不安全的。

如何解决线程并行时所带来的数据安全性问题?

我们可以将运行时的程序看做是一个食堂,每个线程想象成学生,1000个学生争先恐后地冲进食堂,并且数百只手向你索要饭菜时候的样子,场面肯定很混乱,而且人数的拥堵会造成汤水洒一地,或者踩踏事件。

那么这个时候我们很容易想到,为什么你们不排队呢?

这也很顺其自然的想到,我们可不可以将线程从某个时间由并行转变成串行,来保证有序呢,这样既安全,每个人都可以得到满意的菜品。

这就引入了一个概念,

Sychronzied

通过synchronized关键字,我们可以对我们希望的有序的地方进行声明,来达到数据的安全性。sychronzied可以修饰在什么地方?

public class SychronizedDemo {

    public synchronized void demo(){}
    
    public static synchronized  void demo2(){}

    public void demo3(){

        synchronized (this){
            // todo
        }

    }

    public void demo4(){
        synchronized (SychronizedDemo.class){
            // todo
        }
    }
}
  • demo
  • 当我们用于修饰实例方法的时候,意味着这个方法在被访问的时候,必须获得这个实例的锁,才可以进入同步代码。
  • demo2
  • 用于修饰静态代码的时候,只有获得这个类对象的锁才可以访问,因为对于类文件.class,jvm中有且只有一份,所以这样可以保证锁的唯一性。
  • demo3
  • 本质上和demo是一样的,不过可以控制的粒度更小,同步代码块的前面和后面都可以随意反问,但是想要进入代码块必须获得当前实例对象的锁。
  • demo4
  • 锁与demo2相同,粒度与demo3相同。

我们先来看看,加了synchronized的例子是否能够达到我们的预期值。

public class SychronziedThreadDemo {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {

        for(int i =0;i<1000;i++){

            new Thread(()->{
                synchronized(SychronziedThreadDemo.class){
                    try {
                        Thread.sleep(2);//让他改变的时候多延迟一下
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    count ++; // 1000 个线程将会对这个count进行数值修改
                }
            }).start();

        }
        Thread.sleep(3000);
        System.out.println(count);
    }

}

打开关闭CPU超线程 centos_sychonrized_03

1000,这就是我们预期的结果,为什么只是加了这样简单的一句话,就能够解决数据安全性问题呢?

Sychronized 中的锁

我们使用sychronzied关键字的时候,好像并没有使用任何锁的语法,起码没有这种

Lock lock = new Lock();

如上的写法。

比较可疑的就是sychonrzied(...)括号里的东西,值得我们思考。

但因为sychronized是一个关键字,具体的实现,我们通过阅读Hotspot源码才能知道这个关键字到底底层是如何被解释的。

这个先不急,我们来理解一下括号里的东西的意义。

sychonrzied关键后面的东西确实可以被称之为,但是我觉得这个在当前的场景,被称为锁对象更为合理。

为什么我要这样说呢,从上面的sychronzied使用方法不难看出,除了可以用同步代码块来缩小锁的范围,使用不同的生命周期的对象也可以控制锁的范围,如果是实例对象,那么你锁住的就是当前这个实例对象,如果你又重新new了另外一个对象,上一个锁对这个对象就没有多大影响,因为是完全不同的实例对象,但是类对象却不一样,因为只有这一份,即便你new了再多对象也可以保证用的是同一把锁。

看到这里估计又有疑问了,对象和锁到底是什么关系?好像是个对象就可以成为锁一样。

更多的锁信息

锁可以保证我们并行成为串行,像是在电话亭等电话,如果进入电话亭的人不离开电话亭你是无法进去的,因为那里太狭窄,只能容下一个人。

那么这就有锁的一个很重要的特性->互斥特性

  • 我们的锁需要一个标志来表示,当前锁的状态,是被独占了,还是被释放了,还是并没有上锁。后面的线程可以根据这个锁的状态来判断自己是应该竞争锁,还是被阻塞。
  • 同时,这个状态因为被多个线程都是可见的。

想要探索这些,我们就必须知道对象在内存中到底是如何存储的,因为在java层面,我们除了synchronzied这几个英文字母看不出更多信息了。

对象在内存中的布局

Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个部分

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

打开关闭CPU超线程 centos_线程安全_04


我们在这里仿佛看到了锁的影子。