java_多线程
- 线程创建方式;join用法;
- sleep和wait区别;
- 线程安全和不安全的java集合;
- StringBuffer和stringBuilder哪个是线程安全;
- hashmap源码;currentHashmap怎么线程安全;hashtable
- 同步锁关键字 区别;java1.6对synchronized的优化(偏向锁,轻量级锁(cas),重量级锁)
1. 线程创建方式;join用法;
线程的创建方式如下:
- 继承Thread类
class MyThread extends Thread{
//重写run方法
@Override
public void run() {
//任务内容....
System.out.println("当前线程是:"+Thread.currentThread().getName());
}
}
// main方法调用
Thread thread = new MyThread();
//线程启动
thread.start();
- 实现Runable接口
//实现Runnable接口
class MyTask implements Runnable{
//重写run方法
public void run() {
//任务内容....
System.out.println("当前线程是:"+Thread.currentThread().getName());
}
}
// main方法调用
Thread thread = new Thread(new MyTask());
//线程启动
thread.start();
- 使用FutureTask callable
class MyCallable implements Callable<Double>{
@Override
public Double call() {
double d = 0;
try {
System.out.println("异步计算开始.......");
d = Math.random()*10;
d += 1000;
Thread.sleep(2000);
System.out.println("异步计算结束.......");
} catch (InterruptedException e) {
e.printStackTrace();
}
return d;//有返回值
}
}
// 调用
public class Test {
public static void main(String[] args) throws InterruptedException, ExecutionException {
FutureTask<Double> task = new FutureTask(new MyCallable());
//创建一个线程,异步计算结果
Thread thread = new Thread(task);
thread.start();
//主线程继续工作
Thread.sleep(1000);
System.out.println("主线程等待计算结果...");
//当需要用到异步计算的结果时,阻塞获取这个结果
Double d = task.get();
System.out.println("计算结果是:"+d);
}
}
- 使用线程池
public class MyTest {
public static void main(String[] args) {
//创建一个只有一个线程的线程池
ExecutorService executorService = Executors.newSingleThreadExecutor();
//创建任务,并提交任务到线程池中
executorService.execute(new MyRunable("任务1"));
executorService.execute(new MyRunable("任务2"));
executorService.execute(new MyRunable("任务3"));
}
}
class MyRunable implements Runnable{
private String taskName;
public MyRunable(String taskName) {
this.taskName = taskName;
}
@Override
public void run() {
System.out.println("线程池完成任务:"+taskName);
}
}
join()方法:等待该线程终止,意思就是如果在主线程中调用该方法时,就会让主线程休眠,调用该方法的线程的run方法执
行完毕后再开始执行主线程,使用该方法时会抛出一个异常;
yield(): 暂停当前正在执行的线程对象,将其置为准备状态,然后和其他线程一起竞争。
关于run()方法的思考
看看下面这种情况:线程类Thread 接收了外部任务,同时又用匿名内部类的方式重写了内部的run()方法,这样岂不是有两个任务,
那么究竟会执行那个任务呢?还是两个任务一起执行呢?
Thread thread = new Thread(new MyTask()){
@Override
public void run() {//重写Thread类的run方法
System.out.println("Thread 类的run方法");
}
};
//线程启动
thread.start();
//实现Runnable接口
class MyTask implements Runnable{
//重写run方法
@Override
public void run() {
//任务内容....
System.out.println("这是Runnable的run方法");
}
}
运行结果:
Thread 类的run方法
通过上面的结果,可以看出:线程最后执行的是Thread类内部的run()方法,这是为什么呢?我们先来分析一下JDK的Thread源码:
private Runnable target;
public void run() {
if (target != null) {
target.run();
}
}
一切都清晰明了了,Thread类的run方法在没有重写的情况下,是判断一下是否有Runnable 对象传进来,如果有,那么就调用
Runnable 对象里的run方法;否则,就什么都不干,线程结束。所以,针对上面的例子,一旦你继承重写了Thread类的run()
方法,而你又想可以接收Runable类的对象,那么就要加上super.run(),执行没有重写时的run方法,改造的例子如下:
Thread thread = new Thread(new MyTask()){
@Override
public void run() {//重写Thread类的run方法
//调用父类Thread的run方法,即没有重写时的run方法
super.run();
System.out.println("Thread 类的run方法");
}
};
运行结果:
这是Runnable的run方法
Thread 类的run方法
2. sleep和wait区别;
- Sleep Thread类静态方法,任何地方调用,必须捕获异常; 不释放同步锁; 用interrupt打断;
- wait obj方法,在同步块内调用; 释放同步锁; 用notify唤醒;
3. 线程安全和不安全的java集合;
线程安全集合:
Vector HashTable StringBuffer
以Concurrent 开头的集合类,如ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、ConcurrentLinkedQueue 和 ConcurrentLinkedDeque。
以CopyOnWrite 开头的集合类,如CopyOnWriteArrayList、CopyOnWriteArraySet。
线程不安全集合:
ArrayList、LinkedList HashSet、TreeSet HashMap、TreeMap StringBuilder
4. StringBuffer和stringBuilder哪个是线程安全;
StringBuffer是线程安全的
5. hashmap源码;currentHashmap怎么线程安全;hashtable
5.1 hashmap
hashmap1.7: 数组+链表
HashMap1.8: 数组+链表+红黑树
在讲解put方法之前,先看看hash方法,看怎么计算哈希值的。
static final int hash(Object key) {
int h;
/**先获取到key的hashCode,然后进行移位再进行异或运算,为什么这么复杂,不用想肯定是为了减少hash冲突**/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
下面来看看put方法。
public V put(K key, V value) {
/**四个参数,第一个hash值,第四个参数表示如果该key存在值,如果为null的话,则插入新的value,最后一个参数,在hashMap中没有用,可以不用管,使用默认的即可**/
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab 哈希数组,p 该哈希桶的首节点,n hashMap的长度,i 计算出的数组下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
//获取长度并进行扩容,使用的是懒加载,table一开始是没有加载的,等put后才开始加载
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**如果计算出的该哈希桶的位置没有值,则把新插入的key-value放到此处,此处就算没有插入成功,也就是发生哈希冲突时也会把哈希桶的首节点赋予p**/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//发生哈希冲突的几种情况
else {
// e 临时节点的作用, k 存放该当前节点的key
Node<K,V> e; K k;
//第一种,插入的key-value的hash值,key都与当前节点的相等,e = p,则表示为首节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//第二种,hash值不等于首节点,判断该p是否属于红黑树的节点
else if (p instanceof TreeNode)
/**为红黑树的节点,则在红黑树中进行添加,如果该节点已经存在,则返回该节点(不为null),该值很重要,用来判断put操作是否成功,如果添加成功返回null**/
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//第三种,hash值不等于首节点,不为红黑树的节点,则为链表的节点
else {
//遍历该链表
for (int binCount = 0; ; ++binCount) {
//如果找到尾部,则表明添加的key-value没有重复,在尾部进行添加
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//判断是否要转换为红黑树结构
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//如果链表中有重复的key,e则为当前重复的节点,结束循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//有重复的key,则用待插入值进行覆盖,返回旧值。
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//到了此步骤,则表明待插入的key-value是没有key的重复,因为插入成功e节点的值为null
//修改次数+1
++modCount;
//实际长度+1,判断是否大于临界值,大于则扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
//添加成功
return null;
}
添加的步骤如下:
- 是否初始化,如没有,则先初始化
- 通过hash与数组长度与运算计算的数组下表, 如果计算出的该哈希桶的位置没有值,说明没hash冲突,直接把new一个新node放在该数组下标下。
- 如果计算的数组下标已经有值了(p),说明有hash冲突,需要把新key的hash和新key的值与p的hash和p的key值进行对比,分以下几种情况:
第一种,hash和key都相等,e = p,则表示在首节点冲突,会用新的value替换掉首节点的value;
第二种,否则,判断p是否是红黑树,如果是则在红黑树里进行添加;
第三种,否则,说明p是一个数组的首节点的链表结构,遍历这个链表,
如果中间找到一个hash和key都一样的节点,则用新value替换该链表中的节点value;
如果找到末尾都没有重复的,则将新节点添加到链表的末尾,然后判断是否需要转为红黑树(默认节点数大于8会转为红黑树)
下面来看看扩容方法resize。
final Node<K,V>[] resize() {
//把没插入之前的哈希数组做我诶oldTal
Node<K,V>[] oldTab = table;
//old的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//old的临界值
int oldThr = threshold;
//初始化new的长度和临界值
int newCap, newThr = 0;
//oldCap > 0也就是说不是首次初始化,因为hashMap用的是懒加载
if (oldCap > 0) {
//大于最大值
if (oldCap >= MAXIMUM_CAPACITY) {
//临界值为整数的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
//标记##,其它情况,扩容两倍,并且扩容后的长度要小于最大值,old长度也要大于16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//临界值也扩容为old的临界值2倍
newThr = oldThr << 1;
}
/**如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在,
如果是首次初始化,它的临界值则为0
**/
else if (oldThr > 0)
newCap = oldThr;
//首次初始化,给与默认的值
else {
newCap = DEFAULT_INITIAL_CAPACITY;
//临界值等于容量*加载因子
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//此处的if为上面标记##的补充,也就是初始化时容量小于默认值16的,此时newThr没有赋值
if (newThr == 0) {
//new的临界值
float ft = (float)newCap * loadFactor;
//判断是否new容量是否大于最大值,临界值是否大于最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//把上面各种情况分析出的临界值,在此处真正进行改变,也就是容量和临界值都改变了。
threshold = newThr;
//表示忽略该警告
@SuppressWarnings({"rawtypes","unchecked"})
//初始化
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//赋予当前的table
table = newTab;
//此处自然是把old中的元素,遍历到new中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
//临时变量
Node<K,V> e;
//当前哈希桶的位置值不为null,也就是数组下标处有值,因为有值表示可能会发生冲突
if ((e = oldTab[j]) != null) {
//把已经赋值之后的变量置位null,当然是为了好回收,释放内存
oldTab[j] = null;
//如果下标处的节点没有下一个元素
if (e.next == null)
//把该变量的值存入newCap中,e.hash & (newCap - 1)并不等于j
newTab[e.hash & (newCap - 1)] = e;
//该节点为红黑树结构,也就是存在哈希冲突,该哈希桶中有多个元素
else if (e instanceof TreeNode)
//把此树进行转移到newCap中
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { /**此处表示为链表结构,同样把链表转移到newCap中,就是把链表遍历后,把值转过去,在置位null**/
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回扩容后的hashMap
return newTab;
}
扩容方法分为以下几步:
- 遍历旧数组,找出数组对应下标的node节点。
- 如果这个节点没有下一个元素,说明没有冲突,重新在新数组里计算下标并放入(newTab[e.hash & (newCap - 1)] = e;);
- 如果这个节点有下一个元素,如果是红黑树的话,把这个红黑树转移到新数组里;
- 否则,是一个链表,通过(e.hash & oldCap)将该链表均分为两个链表,放在newTab[j]和newTab[j + oldCap]的位置。
其他更详细的信息,可以参考如下链接:
https://blog.csdn.net/m0_37914588/article/details/82287191
https://blog.csdn.net/login_sonata/article/details/76598675
5.1 concurrentHashmap
1.7:ReentrantLock + segment + HashEntry
1.8:synchronize + cas + hashEntry
concurrentHashmap之所以线程安全,是因为1.7里用了lock方法,1.8里用了synchronized关键字;
5.3 hashtable
hashtable线程安全是因为每个增删的操作都加了synchronized关键字;
hashtable 的初始容量为11; hashmap为16;
// Hashtable初始化
public Hashtable() {
this(11, 0.75f);
}
// HashTable扩容
rehash(){
int newCapacity = (oldCapacity << 1) + 1;
}
// HashMap 的初始容量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// HashMap 扩容
resize() {
newThr = oldThr << 1;
}
5.4 如何线程安全的使用HashMap三种方式:
//Hashtable
Map<String, String> hashtable = new Hashtable<>();
//synchronizedMap
Map<String, String> synchronizedHashMap = Collections.synchronizedMap(new HashMap<String, String>());
//ConcurrentHashMap
Map<String, String> concurrentHashMap = new ConcurrentHashMap<>();
6. 同步锁关键字 区别;java1.6对synchronized的优化(偏向锁,轻量级锁(cas),重量级锁)
Java中的每个对象都可以作为锁. 具体变现为以下3中形式.
- 对于普通同步方法, 锁是当前实例对象.
- 对于静态同步方法, 锁是当前类的Class对象.
- 对于同步方法块, 锁是synchronized括号里配置的对象.
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步, 但是两者的实现细节不一样.
- 代码块同步: 通过使用monitorenter和monitorexit指令实现的.
- 同步方法: ACC_SYNCHRONIZED修饰
monitorenter指令是在编译后插入到同步代码块的开始位置, 而monitorexit指令是在编译后插入到同步代码块的结束处或异常处.
6.1 synchronized和volatile的区别
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是
立即可见的。
2)禁止进行指令重排序。
volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;
synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
1.volatile仅能使用在变量级别;
synchronized则可以使用在变量、方法、和类级别的
2.volatile仅能实现变量的修改可见性,并不能保证原子性;
synchronized则可以保证变量的修改可见性和原子性
3.volatile不会造成线程的阻塞;
synchronized可能会造成线程的阻塞。
4.volatile标记的变量不会被编译器优化;
synchronized标记的变量可以被编译器优化
6.2 Java对象头(存储锁类型)
在HotSpot虚拟机中, 对象在内存中的布局分为三块区域: 对象头, 示例数据和对其填充.
对象头中包含两部分: MarkWord 和 类型指针.
如果是数组对象的话, 对象头还有一部分是存储数组的长度.
多线程下synchronized的加锁就是对同一个对象的对象头中的MarkWord中的变量进行CAS操作.
MarkWord
Mark Word用于存储对象自身的运行时数据, 如HashCode, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程ID等等.
占用内存大小与虚拟机位长一致(32位JVM -> MarkWord是32位, 64位JVM->MarkWord是64位).
类型指针
类型指针指向对象的类元数据, 虚拟机通过这个指针确定该对象是哪个类的实例.
对象头的长度
长度 | 内容 | 说明 |
---|---|---|
32/64bit | MarkWord | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadada Address | 存储对象类型数据的指针 |
32/64bit | Array Length | 数组的长度(如果当前对象是数组) |
如果是数组对象的话, 虚拟机用3个字节(32/64bit + 32/64bit + 32/64bit)存储对象头; 如果是普通对象的话, 虚拟机用2字节存储对象头(32/64bit + 32/64bit).
6.3 优化后synchronized锁的分类
级别从低到高依次是:
无锁状态
偏向锁状态
轻量级锁状态
重量级锁状态
锁可以升级, 但不能降级. 即: 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的.
下面看一下每个锁状态时, 对象头中的MarkWord这一个字节中的内容是什么. 以32位为例.
无锁状态
25bit | 4bit | 1bit(是否是偏向锁) | 2bit(锁标志位) |
---|---|---|---|
对象的hashCode | 对象分代年龄 | 0 | 01 |
偏向锁状态
23bit | 2bit | 4bit | 1bit | 2bit |
---|---|---|---|---|
线程ID | epoch | 对象分代年龄 | 1 | 01 |
轻量级锁状态
30bit | 2bit |
---|---|
指向栈中锁记录的指针 | 00 |
重量级锁状态
30bit | 2bit |
---|---|
指向互斥量(重量级锁)的指针 | 10 |
6.4 锁的升级(进化)
6.4-1.偏向锁
偏向锁是针对于一个线程而言的, 线程获得锁之后就不会再有解锁等操作了, 这样可以省略很多开销. 假如有两个线程来竞争该锁话, 那么偏向锁就失效了, 进而升级成轻量级锁了.
为什么要这样做呢? 因为经验表明, 其实大部分情况下, 都会是同一个线程进入同一块同步代码块的. 这也是为什么会有偏向锁出现的原因.
在Jdk1.6中, 偏向锁的开关是默认开启的, 适用于只有一个线程访问同步块的场景.
偏向锁的加锁
当一个线程访问同步块并获取锁时, 会在锁对象的对象头和栈帧中的锁记录里存储锁偏向的线程ID, 以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁, 只需要简单的测试一下锁对象的对象头的MarkWord里是否存储着指向当前线程的偏向锁(线程ID是当前线程), 如果测试成功, 表示线程已经获得了锁; 如果测试失败, 则需要再测试一下MarkWord中偏向锁的标识是否设置成1(表示当前是偏向锁), 如果没有设置, 则使用CAS竞争锁, 如果设置了, 则尝试使用CAS将锁对象的对象头的偏向锁指向当前线程.
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制, 所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁. 偏向锁的撤销需要等到全局安全点(在这个时间点上没有正在执行的字节码). 首先会暂停持有偏向锁的线程, 然后检查持有偏向锁的线程是否存活, 如果线程不处于活动状态, 则将锁对象的对象头设置为无锁状态; 如果线程仍然活着, 则锁对象的对象头中的MarkWord和栈中的锁记录要么重新偏向于其它线程要么恢复到无锁状态, 最后唤醒暂停的线程(释放偏向锁的线程).
总结
偏向锁在Java6及更高版本中是默认启用的, 但是它在程序启动几秒钟后才激活. 可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁, 那么程序会直接进入轻量级锁状态.
6.4-2.轻量级锁
当出现有两个线程来竞争锁的话, 那么偏向锁就失效了, 此时锁就会膨胀, 升级为轻量级锁.
轻量级锁加锁
线程在执行同步块之前, JVM会先在当前线程的栈帧中创建用户存储锁记录的空间, 并将对象头中的MarkWord复制到锁记录中. 然后线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录的指针. 如果成功, 当前线程获得锁; 如果失败, 表示其它线程竞争锁, 当前线程便尝试使用自旋来获取锁, 之后再来的线程, 发现是轻量级锁, 就开始进行自旋.
轻量级锁解锁
轻量级锁解锁时, 会使用原子的CAS操作将当前线程的锁记录替换回到对象头, 如果成功, 表示没有竞争发生; 如果失败, 表示当前锁存在竞争, 锁就会膨胀成重量级锁.
总结
总结一下加锁解锁过程, 有线程A和线程B来竞争对象c的锁(如: synchronized(c){} ), 这时线程A和线程B同时将对象c的MarkWord复制到自己的锁记录中, 两者竞争去获取锁, 假设线程A成功获取锁, 并将对象c的对象头中的线程ID(MarkWord中)修改为指向自己的锁记录的指针, 这时线程B仍旧通过CAS去获取对象c的锁, 因为对象c的MarkWord中的内容已经被线程A改了, 所以获取失败. 此时为了提高获取锁的效率, 线程B会循环去获取锁, 这个循环是有次数限制的, 如果在循环结束之前CAS操作成功, 那么线程B就获取到锁, 如果循环结束依然获取不到锁, 则获取锁失败, 对象c的MarkWord中的记录会被修改为重量级锁, 然后线程B就会被挂起, 之后有线程C来获取锁时, 看到对象c的MarkWord中的是重量级锁的指针, 说明竞争激烈, 直接挂起.
解锁时, 线程A尝试使用CAS将对象c的MarkWord改回自己栈中复制的那个MarkWord, 因为对象c中的MarkWord已经被指向为重量级锁了, 所以CAS失败. 线程A会释放锁并唤起等待的线程, 进行新一轮的竞争.
6.4-3.锁的比较
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗, 和执行非同步代码方法的性能相差无几. | 如果线程间存在锁竞争, 会带来额外的锁撤销的消耗. | 适用于只有一个线程访问的同步场景 |
轻量级锁 | 竞争的线程不会阻塞, 提高了程序的响应速度 | 如果始终得不到锁竞争的线程, 使用自旋会消耗CPU | 追求响应时间, 同步快执行速度非常快 |
重量级锁 | 线程竞争不适用自旋, 不会消耗CPU | 线程堵塞, 响应时间缓慢 | 追求吞吐量, 同步快执行时间速度较长 |