为什么要使用多线程
在硬件条件不断进步的今天,我们现在常用的电脑已经不是简单的单核CPU,而是4核、8核、甚至更多。
而如果不使用多线程技术的话,一般我们就只用一个CPU来处理程序上的计算问题,复杂且庞大的计算量全部压在一个CPU上,其它CPU只负责划水,那么这无法物尽其用。
当然我们也不是说可怜
这一个CPU,而是真实的环境下,使用多核CPU以及超线程技术可以实现并行,这意味着我们可以在单位时间内处理更多的请求,提升吞吐量。
多线程带来的安全性问题
一个技术的提出,有好处当然也有弊端。虽然多线程技术可以让吞吐量提升,但是多线程技术在运用中会带来许多安全性问题,一个比较常见的就是对共享数据的访问
我们知道,每个线程单独独立,因为他们拥有自己的内存空间,彼此不干扰,那么当两个没有任何关系的线程去对同时一个值进行修改的时候,谁先谁后?谁又能够保证谁拿到的是修改后的值呢?如果修改了,其它线程是否可以及时拿到更新后的值呢?
这里是不是有点像数据库中,脏读
的概念呢?
两个线程同时对变量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);
}
}
以上代码多测试几遍,你会发现有些结果会与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);
}
}
1000,这就是我们预期的结果,为什么只是加了这样简单的一句话,就能够解决数据安全性问题呢?
Sychronized 中的锁
我们使用sychronzied
关键字的时候,好像并没有使用任何锁的语法,起码没有这种
Lock lock = new Lock();
如上的写法。
比较可疑的就是sychonrzied(...)
括号里的东西,值得我们思考。
但因为sychronized
是一个关键字,具体的实现,我们通过阅读Hotspot
源码才能知道这个关键字到底底层是如何被解释的。
这个先不急,我们来理解一下括号里的东西的意义。
sychonrzied
关键后面的东西确实可以被称之为锁
,但是我觉得这个在当前的场景,被称为锁对象
更为合理。
为什么我要这样说呢,从上面的sychronzied
使用方法不难看出,除了可以用同步代码块来缩小锁的范围,使用不同的生命周期的对象也可以控制锁的范围,如果是实例对象,那么你锁住的就是当前这个实例对象,如果你又重新new
了另外一个对象,上一个锁对这个对象就没有多大影响,因为是完全不同的实例对象,但是类对象却不一样,因为只有这一份,即便你new了再多对象也可以保证用的是同一把锁。
看到这里估计又有疑问了,对象和锁到底是什么关系?好像是个对象就可以成为锁一样。
更多的锁信息
锁可以保证我们并行成为串行,像是在电话亭等电话,如果进入电话亭的人不离开电话亭你是无法进去的,因为那里太狭窄,只能容下一个人。
那么这就有锁的一个很重要的特性->互斥特性
- 我们的锁需要一个标志来表示,当前锁的状态,是被独占了,还是被释放了,还是并没有上锁。后面的线程可以根据这个锁的状态来判断自己是应该竞争锁,还是被阻塞。
- 同时,这个状态因为被多个线程都是可见的。
想要探索这些,我们就必须知道对象在内存中到底是如何存储的,因为在java
层面,我们除了synchronzied
这几个英文字母看不出更多信息了。
对象在内存中的布局
在Hotspot
虚拟机中,对象在内存中的存储布局,可以分为三个部分
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
我们在这里仿佛看到了锁的影子。