java基础
- 1. 集合
- 1.1 概述
- 1.2 Collection集合
- 常用方法:
- 迭代器
- List集合
- ArrayList集合
- LinkedList
- Vector集合
- 泛型
- HashSet集合
- TreeSet集合
- 1.3 Map集合
- HashMap
- Hashtable
- Properties
- TreeMap
- Collections集合工具类
- 1.4 面试题
- 2. IO流
- 2.1 概述
- 2.2 流
- 文件专属
- 转换流
- 缓冲流
- 数据流
- 标准输出流
- 对象专属流
- 序列化流
- IO和Properties联合使用
- 2.3 File类
- 拷贝目录
- 3. 多线程
- 3.1 概述
- 3.2 实现线程的两种方式
- 3.3 线程的生命周期
- 3.4 相关方法
- 3.5 线程调度
- 3.6 线程安全(重点)
- 同步代码块synchronized
- 死锁现象
- 3.7 守护线程和定时器
- 3.8 实现线程的第三种方式
- 3.9 生产者消费者模型
- 4. 遗漏的基础
- 4.1 static关键字
- 4.2 多态
- 4.3 抽象类和接口
1. 集合
1.1 概述
数组其实就是一个集合,集合实际上就是一个容器,可以来容纳其他类型的数据。
在实际开发中,假设连接数据库,数据库当中有10条记录,那么假设把这10条记录查询出来,在java程序中会将10条数据封装成10个java对象,然后将10个java对象放到某一个集合当中,将集合传到前端,然后遍历集合,将一个个数据展现出来。
集合不能直接存储基本数据类型,也不能直接存储java对象,集合当中存储的都是java对象的内存地址。集合中任何时候存储都是“引用”。
不同的集合,底层对应不同的数据结构。往不同的集合中存储元素,等于将数据放到了不同的数据结构中。
重点掌握:什么情况下选择哪一种合适的集合去使用。
集合的继承结构图:
总结:
ArrayList:底层是数组。
LinkedList:底层是双向链表
Vector:底层是数组,线程安全,效率较低,使用较少。
HashSet:底层是HashMap,放到HashSet集合中的元素等同于放到HashMap集合的key部分了。
TreeSet:底层是TreeMap,放到TreeSet集合中的元素等同于放到了TreeMap集合key部分了。
HashMap:底层是哈希表。
Hashtable:底层也是哈希表,只不过是线程安全的,效率较低,使用较少。
Properties:是线程安全的,并且key和value只能存储字符串String。
TreeMap:底层是二叉树。TreeMap集合的key可以自动按照大小顺序排序。
List集合存储元素的特点:有序可重复。(有序是指存进和取出的顺序相同,每一个元素都有下标)
Set集合存储元素的特点:无序不可重复。
SortedSet(SortedMap)集合存储元素的特点:无序不重复,按照元素大小进行排序。
1.2 Collection集合
如果没有使用泛型,Collection可以存储Object的所有子类型;使用泛型之后,只能存储某个具体的类型。
常用方法:
add、size、clear、contains、remove、isEmpty、toArray、Iterator
注意:contains方法和remove方法,底层调用了equals方法。
//contains方法是用来判断是否包含某个元素
Collection c = new ArrayList();
String s1 = new String("abc");
c.add(s1);
String x = new String("abc");
c.contains(x); //虽然没有用add方法添加x,但这里还是返回true。
//因为底层调用equals方法,比较的是内容
User u1 = new User("jack");
c.add(u1);
User u2 = new User("jack");
c.contains(u2); //如果User没有重写equals方法,返回false;重写了equals方法,返回true。
没有重写equals方法,底层会进行内存的比较。
结论:存放在一个集合中的类型,一定要重写equals方法。
String s1 = new String("hello");
c.add(s1);
String s2 = new String("hello");
c.remove(s2); //上面添加的s1,这里删除s2
sout(c.size); //返回0
-
深入Collection集合的contains方法
contains方法是用来判断集合中是否包含某个元素的方法。如果包含返回true,如果不包含返回false。
它在底层是如何判断集合中是否包含某个元素的呢?
它是调用了equals方法进行比对的,
equals方法返回true,就表示包含这个元素。public static void main(String[] args) { Collection c = new ArrayList(); //向集合中存储元素 String s1 = new String("abc"); c.add(s1); String s2 = new String("def"); c.add(s2); String x = new String("abc"); //没往集合中存储x // 问:集合中是否包含x? System.out.println(c.contains(x)); //这句相当是在判断 c集合包不包含"abc" }
contains()比较对象:
如果对象没有重写equals方法,即使值相同也返回false,因为contains会调用equals方法,而对象没重写equals方法,则会去调用Object类的equals方法,Object类的equals方法是用 “==” 进行比较的。Collection c = new ArrayList(); User u1 = new User("jack"); c.add(u1); User u2 = new User("jack"); // user对象没有重写equals之前,这个结果为false System.out.println(c.contains(u2));
-
关于集合中remove的操作
remove方法也调用了equals方法,下面删掉s2,集合中s1也没了。Collection c = new ArrayList(); String s1 = new String("hello"); c.add(s1); String s2 = new String("hello"); c.remove(s2); System.out.println(c.size()); // 0
迭代器要在操作完集合后获取。集合的结构只要发生改变,迭代器必须重新获取。
所以,在用迭代器遍历集合的时候,不能调用对象的remove方法去删除集合中的元素,不然会出现异常。
要用迭代器中的remove方法删除。Collection c = new ArrayList(); c.add("abc"); c.add("efg"); Iterator it = c.iterator(); while(it.hasNext()) { Object o = it.next(); it.remove(); //删除当前指向的元素 }
使用对象的remove删除会出现异常,原因是集合中元素删除了,没有更新迭代器。
用迭代器去删除,会自动更新迭代器,并且更新集合。
迭代器
这里的迭代方式,在所有的Collection以及子类中可以使用。
三个方法:boolean hasNext()、object next()、void remove()。
hasNext():是否还有元素可以迭代
next():让迭代器前进一位,并且拿到元素。
boolean hasNext = it.hasNext();
如果返回true,表示还有元素可以迭代;返回false表示没有更多的元素可以迭代了。
Collection c = new ArrayList();
Iterator it = c.iterator(); //拿到迭代器对象
while(it.hasNext()) {
Object obj = it.next();
sout(obj);
}
List集合
list集合:有序可重复。是Collection接口的子接口,所以可以使用Collection的方法,它也有自己“特色”的方法。
- 特有的常用方法
void add(int index, Object element)、Object get(int index)
int indexOf(Object o)、int lastIndexOf(Object o)、Object remove(int index)
Object set(int index, Object element)List myList = new ArrayList();
ArrayList集合
底层采用的数组这个数据结构。
优点:检索效率比较高。
缺点:随机增删元素效率比较低(但在数组末尾增删,效率不受影响);数组无法存储大数据量。
ArrayList是非线程安全的,线程安全的集合效率会低一点。
ArrayList集合初始化容量是10,可以在创建的时候指定容量new ArrayList(20);
ArrayList集合用的比较多,因为往数组末尾增删元素,效率不受影响,而且我们检索/查找某个元素的操作比较多。
- ArrayList集合扩容
源码中说的是:新容量 = 原容量 + 原容量>>1(位运算)
,也就是新容量 = 原容量 + 原容量/2
原容量的1.5倍。
ArrayList底层是数组,数组扩容效率比较低,所以要尽可能少的扩容。建议在使用ArrayList集合的时候预估记元素的个数,给定一个初始化容量。 - ArrayList集合的另一个构造方法
List list1 = new ArrayList(); //底层用的数组 List list2 = new ArrayList(100); Collection c = new HashSet(); c.add(100); c.add(230); c.add(1320); //通过这个构造方法可以将HashSet集合转换成List集合 List list3 = new ArrayList(c);
- 将ArrayList变成线程安全
List list = new ArrayList(); //非线程安全 Collections.synchronizedList(list); list.add("dfsdfs"); ...
LinkedList
LinkedList底层采用双向链表数据结构。
单向链表:
节点是单向链表中基本的单元。每个节点Node都有两个属性:存储数据;下一个节点的内存地址。
优点:由于链表上的元素在空间存储上内存地址不连续,随机增删元素效率较高(因为链表增删不涉及大量元素的位移)。在以后开发中,如果遇到随机增删集合中元素的业务比较多,建议使用LinkedList。
缺点:查询效率较低,每一次查找某个元素的时候都需要从头节点开始往下遍历。
ArrayList:把检索发挥到极致。(末尾添加元素,效率很高)
LinkedList:把随机增删发挥到极致。
加元素,往往是加在集合末尾,所以ArrayList用的比LinkedList多。
注意:LinkedList集合底层也是有下标的。
ArrayList之所以检索效率比较高,不单纯因为下标,是底层数组发挥的作用。
LinkedList照样有下标,但是检索某个元素的时候,只能从头节点开始一个一个遍历。
List list = new LinkedList(); //底层用双向链表
list.add("a");
list.add("b");
list.add("c");
for(int i = 0; i < list.size(); i++) {
Object obj = list.get(i);
sout(obj);
}
1)单向链表,查找的方向只能是一个方向,而双向链表可以向前或者向后查找
2)单向链表不能自我删除,需要靠辅助节点,而双向链表,则可以自我删除,所以前面我们单链表删除节点,总是找到temp,temp是待删除节点的前一个节点。
Vector集合
底层也是一个数组,初始化容量为10。
扩容:扩容之后是原来容量的2倍。
Vector中所有的方法都是线程同步的,都带有synchronized关键字,是线程安全的。效率比较低,使用较少了。
泛型
泛型这种语法机制,只在程序编译阶段起作用,只是给编译器参考的。
使用泛型的好处:
集合中存储的元素类型统一了。
从集合中取出的元素类型是泛型指定的类型,不需要进行大量的“向下转型”。
缺点:
导致集合中存储的元素缺乏多样性。
大多数业务中,集合中元素的类型还是统一的,所以这种泛型特性被大家所认可。
List<Animal> myList = new ArrayList<Animal>(); //使用泛型,只能存储指定的类型
Cat c = new Cat();
Bird b = new Bird();
myList.add(c);
myList.add(b);
Iterator<Animal> it = myList.iterator();
while(it.hasNext()){
//这里不用再进行强制类型转换了,直接调用。
Animal a = it.next(); //迭代器取出来就是Animal类型
a.move();
/*
if(obj instanceof Animal) {
Animal a = (Animal)obj;
a.move();
}
*/
//不过调用子类的特有方法,还是要转型的
}
在JDK1.8之后,引入了自动类型推断机制:
List<Animal> list = new ArrayList<>();
就是后面尖括号可以省略类型。
- 自定义泛型
<>中的T是一个标识符,随便写。
java源码中经常出现的是:<E>
和<T>
public class Test<T> { public T get() { return null; } public void dosome(T o) { sout(o); } public static void main(String[] args) { Test<String> t = new Test<>(); String s = t.get(); //这里只能返回String类型,因为设置了泛型 Test<String> t2 = new Test<>(); t2.dosome("abc"); } }
HashSet集合
存和取的顺序不一样;不可重复;
放到HashSet集合中的元素实际上是放到了HashMap集合的key部分了。
TreeSet集合
无序不可重复,但是存储的元素可以自动按照大小顺序排序。(称为可排序集合)
无序指的是:没有下标,存和取的顺序可能不同。
1.3 Map集合
Map集合以key和value的方式存储数据,key和value都是引用数据类型。
-
常用方法
V put(key, value):向Map集合中添加键值对、
V get(Object key):通过key获取value、
void clear():清空map集合、
boolean isEmpty():判断集合中元素个数是否为0、
boolean containsKey(Object key):是否包含某个key、
boolean containsValue(Object value):判断Map中是否包含某个value、
Set<K> keySet()
:获取集合所有的key 、
V remove(Object key):通过key删除键值对、
int size():获取集合中键值对的个数、
Collection<V> values()
:获取集合中所有的value、
Set<Map.Entry<K,V>> entrySet():将集合转换成Set集合,1=zhangsan -
Map集合的遍历
方式一:先拿到Map集合所有的key,根据key,获取value。
Map<Integer,String> map = new HashMap<>(); map.put(1,"zhangsan"); ... Set<Integer> keys = map.keySet(); //拿到所有key Iterator<Integer> it = keys.iterator(); //拿到Set集合的迭代器 while(it.hasNext()) { Integer key = it.next(); //拿到一个key String value = map.get(key); //根据key获取value sout(key + "=" + value); }
不想用迭代器,用增强for
Set<Integer> keys = map.keySet(); //拿到所有key for(Integer key : keys) { sout(key + "=" + map.get(key)); }
方式二:Set<Map.Entry<K,V>> entrySet(),将map集合转换成set集合
//先将Map集合转换成Set集合 Set<Map.Entry<Integer,String>> set = map.entrySet(); //拿到迭代器 Iterator<Map.Entry<Integer,String>> it2 = set.iterator(); //通过迭代器遍历set集合中的元素,元素对象是Map.Entry<Integer,String>类型 while(it2.hasNext()) { Map.Entry<Integer,String> node = it2.next(); //拿到对象中的key Integer key = node.getKey(); //拿到对象中的value String value = node.getValue(); sout(key + "=" +value); }
用增强for
for(Map.Entry<Integer,String> node : set) { sout(node.getKey() + "--->" + node.getValue()); }
第二种方式效率比较高,因为获取key和value都是直接从node对象找中获取的属性值。
HashMap
HashMap集合底层是 哈希表/散列表 的数据结构,非线程安全。
-
哈希表数据结构
哈希表是一个一维数组和单向链表的结合体。(数组中的每个元素是一个单向链表)
数组:在查询方面效率很高,随机增删方面效率很低。
单向链表:在随机增删方面效率较高,在查询方面效率很低。
哈希表将以上的两种数据结构融合在一起,充分发挥它们各自的优点。HashMap集合底层的源代码:
public class HashMap { //HashMap底层实际上就是一个一维数组 Node<K,V>[] table; static class Node<K,V> { //哈希值,哈希值是key的hashCode()方法的执行结果。hash值通过哈希函数/算法,可以转换存储成数组的下标。 final int hash; //在这里可以理解成数组下标 final K key; //存储到Map集合中的key V value; //存储到Map集合中的value Node<K,V> next; //下一个节点的内存地址 } }
哈希表/散列表:一维数组,这个数组中每一个元素是一个单向链表。
重点:要知道put怎么存,get怎么取。
为什么哈希表的随机增删,以及查询效率都很高?
因为增删是在链表上完成的,查询也不需要全都扫描,只需要部分扫描。通过图可以得出:HashMap集合的key,会先后调用两个方法,一个方法是hashCode(),一个方法是equals(),那么这两个方法都需要重写。
注意:同一个单向链表上所有节点的hash相同,因为它们的数组下标是一样的。但同一个链表上key和key的equals方法肯定返回的是false,都不相等。
假设HashCode()方法返回值固定为某个值,那么底层哈希表会变成纯单向链表;假设HashCode()方法方法返回值都设定为不一样的值,那么底层哈希表就成一维数组了,不会在数组元素下挂链表(散列分布不均匀)。散列分布均匀,需要你重写hashCode()方法时有一定的技巧。
-
重点:放在HashMap集合key部分的元素,以及放在HashSet集合中的元素,需要同时重写hashCode和equals方法。
向Map集合中存,以及从Map集合中取,都是先调用key的hashCode方法,再可能调用equals方法(equals可能调用也可能不调用)。数组下标位置上如果是null,equals不需要执行。
一个类如果equals方法重写了,那么hashCode()方法必须重写。 -
HashMap集合的默认初始化容量是16,默认加载因子是0.75
默认加载因子:集合底层数组的容量达到75%的时候,数组开始扩容。
重点:
记住,HashMap集合初始化容量必须是2的倍数,这也是官方推荐的。这是为了达到散列均匀,为了提高HashMap集合的存取效率所必须的。 -
最终结论:
放在HashMap集合中的key元素,以及放在HashSet集合中的元素,需要同时重写hashCode方法和equals方法。 -
JDK8之后,如果哈希表单向链表中元素 超过8个,单向链表这种数据结构会变成红黑树数据结构。当红黑树上的节点数量小于6时,会重新把红黑树变成单向链表数据结构。
Hashtable
线程安全。底层也是哈希表数据结构。
初始化容量是11,默认加载因子是:0.75f,扩容是:原容量*2 + 1
- HashTable与HashMap比较
HashMap的key和value可以为null
而Hashtable的key和value不能为nullMap map = new HashMap(); map.put(null,null);
Properties
Properties是一个Map集合,继承Hashtable,Properties的key和value都是String类型。Properties被称为属性类对象。Properties是线程安全的。
掌握两个方法,一个存,一个取。
Properties pro = new Properties();
pro.setProperty("url","jdbc:mysql://localhost:3306/ld");
pro.getProperty("url");
TreeMap
TreeMap集合底层是一个二叉树。TreeSet集合底层实际上是一个TreeMap,放到TreeSet集合中的元素,等同于放到TreeMap集合key部分了。
TreeSet集合中的元素,无序不可重复,但是可以按照元素的大小顺序自动排序(升序)。
-
TreeSet对自定义类型的排序
自定义类型要实现Comparable接口,不然会出现异常,因为TreeSet要进行排序,如果自定义类型没有指定对象之间的比较规则,就会出错。Customer c1 = new Customer(32); Customer c2 = new Customer(20); Customer c3 = new Customer(30); Customer c4 = new Customer(25); TreeSet<Customer> customers = new TreeSet<>(); customers.add(c1); customers.add(c2); customers.add(c3); customers.add(c4); for(Customer c : customers) { sout(c); } class Customer implements Comparable<Customer> { int age; public Customer(int age) { this.age = age; } //比较的规则,kparaTo(t.key)。参数k和集合中的每一个k进行比较。 //可以指定升序或者降序 @Override public int comparaTo(Customer c) { int age1 = this.age; int age2 = c.age; if(age1 == age2) { return 0; } else if(age1 > age2) { return 1; } else { return -1; } // return this.age - c.age; 升序 } }
-
比较器(第二种排序规则)
//创建TreeSet集合的时候,需要使用这个比较器 //TreeSet<Customer> customers = new TreeSet<>(); 这样不行,没有通过构造方法传递一个比较器进去 TreeSet<Customer> customers = new TreeSet<>(new CusComparator()); customers.add(new Custom(1000)); customers.add(new Custom(48)); customers.add(new Custom(23)); customers.add(new Custom(90)); //这里省去Custom类的编写。 ... //单独写一个比较器。比较器实现Comparator接口。 class CusComparator implements Comparator<Custom> { @Override public int compare(Custom o1, Custom o2) { return o1.age - o2.age; } }
使用匿名内部类:可以不写比较器。
TreeSet<Customer> customers = new TreeSet<>(new CusComparator<Custom>(){ @Override public int compare(Custom o1, Custom o2) { return o1.age - o2.age; } }); customers.add(new Custom(1000)); ...
最终结论:
放到TreeSet或TreeMap集合key部分的元素想做到排序,两种方式实现:
第一种:放在集合中的元素,实现Comparable接口。
第二种:在构造TreeSet或TreeMap集合的时候给它传一个比较器对象。两种方式的选择:
当比较规则不会发生改变的时候,或者说当比较规则只有1个的时候,建议实现Comparable接口;如果比较规则有多个,并且需要多个比较规则之间频繁切换,建议使用Comparator接口。 -
自平衡二叉树
TreeSet/TreeMap是自平衡二叉树,遵循左小右大原则存放。
遍历二叉树有三种方式:前序遍历、中序遍历、后序遍历。
TreeSet/TreeMap集合采用中序遍历方式。
Collections集合工具类
集合工具类,方便集合操作。
List<String> list = new ArrayList<>();
Collections.synchronizedList(list); //变成线程安全
list.add("abf");
list.add("tff");
list.add("fdsf");
Collections.sort(list); //排序
注意:对list集合中元素排序,需要保证list集合中的元素实现了Comparable接口。
String类中实现了Comparable接口,可以直接排序。如果是自定义类型,要自己实现。
sort排序只能对List集合排序,不能对Set排序。如果想对Set集合中的元素排序,要将Set集合转换为List集合。
Set<String> set = new HashSet<>();
set.add("fkasd");
set.add("tret");
set.add("gdfg");
List<String> myList = new ArrayList<>(set);
Collections.sort(myList);
1.4 面试题
HashMap 中 hash 函数是怎么实现的?还有哪些hash函数的实现方式?
答:对于 key 的 hashCode 做 hash 操作,无符号右移 16 位然后做异或运算。还有平方取中法,伪随机数法和取余数法。这三种效率都比较低。而无符号右移 16 位异或运算效率是最高的。
当两个对象的 hashCode 相等时会怎么样?
答:会产生哈希碰撞。若 key 值内容相同则替换旧的 value,不然连接到链表后面,链表长度超过阈值 8 就转换为红黑树存储。
什么是哈希碰撞,如何解决哈希碰撞?
答:只要两个元素的 key 计算的哈希码值相同就会发生哈希碰撞。jdk8 之前使用链表解决哈希碰撞。jdk8之后使用链表 + 红黑树解决哈希碰撞。
如果两个键的 hashCode 相同,如何存储键值对?
答:通过 equals 比较内容是否相同。相同:则新的 value 覆盖之前的 value。不相同:则将新的键值对添加到哈希表中。
2. IO流
2.1 概述
通过IO可以完成硬盘文件的读和写。
IO流的分类:
1. 按照流的方向进行分类,以内存作为参照物。
往内存中去,叫做输入(Input),或者叫做读(Read)。
从内存中出来,叫做输出(Output),或者叫做写(Write)。
2. 按照读取数据方式不同进行分类
按照字节的方式读取数据,一次读取1个字节byte,等同于一次读取8个二进制位。这种流是万能的,什么类型的文件都可以读取。包括:文本文件,图片,声音文件,视频等。
按照字符的方式读取数据,一次读取一个字符,这种流是为了方便读取普通文本文件而存在的,这种流不能读取:图片,声音,视频等文件。只能读取普通文本文件,连word文件都无法读取(word不是纯文本文件)。能用记事本打开的,都是普通文本文件。
学习方法:java中的IO流都已经写好了,我们程序员不需要关心,我们最主要还是掌握,在java中已经提供了哪些流,每个流的特点是什么,每个流对象上的常用方法有哪些。
java中所有的流都是在:java.io.*;下
主要研究:怎么new流对象,调用流对象的哪个方法是读,哪个方法是写。
java IO流的四大家族
java.io.InputStream 字节输入流
java.io.OutputStream 字节输出流
java.io.Reader 字符输入流
java.io.Writer 字符输出流
它们都是抽象类。
所有流都实现了:java.io.Closeable接口,都是可关闭的,都有**close()**方法。
流毕竟是一个管道,这个是内存和硬盘之间的通道,用完之后一定要关闭,不然会消耗(占用)很多资源。所以,用完流一定要关闭。
所有的输出流都实现了:java.io.Flushable接口,都是可刷新的,都有**flush()**方法。
养成一个好习惯,输出流在最终输出之后,一定要记得flush()刷新一下。这个刷新表示将通道/管道当中剩余未输出的数据强行输出完(清空管道)。刷新的作用就是清空管道。
注意:如果没有flush()可能会导致丢失数据。
注意:在java中只要“类名”以Stream结尾的都是字节流。以“Reader/Writer”结尾的都是字符流。
2.2 流
文件专属
-
FileInputStream
文件字节输入流,万能的,可以采用这个流读任何类型的文件。
以字节的方式,完成输入的操作,完成读的操作(硬盘–>内存)public static void main(String[] args) { FileInputStream fis = null; try { //fis = new FileInputStream("D:\\course\\temp"); // 转义\ fis = new FileInputStream("D:/course/temp"); //这两种路径都可以 //开始读 int readData = 0; while((readData = fis.read()) != -1) { //读不到了,返回-1 sout(readData); } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } finally { //在finally语句块中确保流一定关闭 if (fis == null) { //避免空指针异常 try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } } }
这个程序的缺点:一次读取一个字节byte,这样内存和硬盘交互太频繁,时间/资源都耗费在交互上了。
用
int read(byte[] b)
一次读取多个字节,提高执行效率。最多读取“数组.length”个字节。fis = new FileInputStream("demo/src/temp"); byte[] bytes = new byte[4]; //准备一个数组,一次读4个字节 int readCount = 0; //int read(byte[] b)的返回值是读到字节的数量 while((readCount = fis.read(bytes)) != -1) { //把byte数组转换成字符串,读到多少个转换多少个。 sout(new String(bytes, 0 , readCount)); }
为什么要用
String(bytes, 0 , readCount)
这个方法?
首先要将读到的字节转换成字符串输出,但是输出不能一次将数组中的值都输出,数组中可能有上一次读取的数据没有被替换掉,所以要用索引截取。
路径的使用:工程Project的根路径是IDEA的默认当前路径。比如上面这个路径,文件temp是放在了demo项目下的src目录下。
常用方法
int available():返回流当中剩余的没有读到的字节数量fis = new FileInputStream("temp"); sout("总字节数量:" + fis.available()); int readByte = fis.read(); //读了一个字节 sout("剩下多少个字节没有读:" + fis.available()); //假设文件有6个字节,这里返回5
一般用法
byte[] bytes = new byte[fis.available()]; //这种方式不适合太大的文件,因为byte[]数组不能太大 int readCount = fis.read(bytes); sout(new String(bytes)); //一次都读出来,不需要循环了
long skip(long n):跳过几个字节不读
fis.skip(3); //跳过3个字节不读 sout(fis.read());
-
FileOutputStream
文件字节输出流,负责写,从内存到硬盘。
FileOutputStream(File file):指定路径中,文件不存在则会新建一个fos = new FileOutputStream("myfile"); String s = "我是一个中国人,哈哈哈!!!"; byte[] bs = s.getBytes(); //将字符串转换成byte数组 fos.write(bs); fos.flush(); //写完之后,最后一定要刷新
上面写,会先将原文件清空再往里面写,谨慎使用。
FileOutputStream(String name, boolean append):在文件后面追加内容,不会清空原文件。fos = new FileOutputStream("chapter23/src/tempfile3", true); byte[] bytes = {97,98,99,100}; fos.write(bytes);//将byte数组的全部写出,abcd fos.write(bytes, 0, 2); //再写出ab fos.flush(); //写完之后,最后一定要刷新
-
文件复制
使用FileInputStream + FileOutputStream 完成文件的拷贝。FileInputStream fis = null; FileOutputStream fos = null; try { //创建一个输入流对象 fis = new FileInputStream(""); //创建一个输出流对象 fos = new FileOutputStream(""); //最核心,一边读一边写 byte[] bytes = new byte[1024*1024]; //一次最多拷贝一兆 int readCount = 0; while((readCount = fis.read(bytes)) != -1) { fos.write(bytes, 0, readCount); } fos.flush(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } finally { //两个异常分开try,不然其中一个出现异常,可能会影响到另一个流的关闭 if (fis == null) { try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } if (fos != null) { try { fos.close(); } catch (IOException e) { e.printStackTrace(); } } }
-
FileReader
文件字符输入流,只能读取普通文件。读取文件内容时,比较方便,快捷。reader = new FileReader("tempfile"); //创建文件字符输入流 char[] chars = new char[4]; //一次读取4个字符 int readCount = 0; while((readCount = reader.read(chars)) != -1) { sout(new String(chars, 0, readCount)); }
-
FileWriter
文件字符输出流,写。只能输出普通文本。out = new FileWriter("file", true); char[] chars = {'我','是','中','国','人'}; out.write(chars); out.write(chars, 2, 3); out.write("我是一名java软件工程师"); out.write("\n"); out.write("hello world!"); out.flush();
转换流
将字节流转换成字符流。也可以转换文件编码。
InputStreamReader
OutputStreamWriter
缓冲流
- BufferedReader
带有缓冲区的字符输入流。使用这个流的时候,不需要自定义char数组,或者说不需要自定义byte数组。自带缓冲。
注意:BufferedReader(Reader r) 这个构造方法,只能传字符流,不能传字节流。我们可以通过转换流转换。FileReader reader = new FileReader("Copy02.java"); //当一个流的构造方法中需要一个流的时候,这个被传进来的流叫做:节点流 //外部负责包装的这个流,叫做:包装流,还有一个名字叫做:处理流 //像当前这个程序来说,FileReader就是一个节点流。BufferedReader就是包装流/处理流 BufferedReader br = new BufferedReader(reader); String s = null; //readLine,读一个文本行,但不带最后的换行符 while((s = br.readLine()) != null) { sout(s); } //关闭流 //看源码得知,对于包装流来说,只需要关闭最外层流就行,里面的节点流会自动关闭 br.close();
//字节流 FileInputStream in = new FileInputStream("Copy02.java"); //通过转换流 转换 InputStreamReader reader = new InputStreamReader(in); BufferedReader br = new BufferedReader(reader);
- BufferedWriter
BufferedReader out= new BufferedWriter(new OutputStreamWriter(new FileOutputStream("Copy"))); out.write("hello world!"); out.write("\n"); out.write("hello kitty!"); out.flush(); out.close();
- BufferedInputStream
- BufferedOutputStream
数据流
- DataOutputStream
数据专属的流。
这个流可以将数据连同数据的类型一并写入文件。
注意:这个文件不是普通的文本文档(用记事本打不开)//创建数据专属的字节输出流 DataOutputStream dos = new DataOutputStream(new FileOutputStream("data")); byte b = 100; short s = 200; int i = 300; dos.writeByte(b); //把数据以及数据的类型一并写入到文件当中 dos.writeByte(s); dos.writeByte(i); dos.close();
- DataInputStream
数据字节输入流。
写的文件,只能使用DataInputStream去读,并且读的时候你需要提前知道写入的顺序。读的顺序需要和写的顺序一致,才可以正常取出数据。DataInputStream dis= new DataInputStream (new FileInputStream("data")); byte b = dis.readByte(); short s = dis.readShort(); int i = dis.readInt(); sout(b); sout(s); sout(i); dos.close();
标准输出流
- PrintWriter
- PrintStream
标准的字节输出流,默认输出到控制台。
可以改变标准字节输出流的方向。//联合起来写 System.out.println("hello world!"); //分开写 PrintStream ps = System.out; ps.println("hello zhangsan"); //标准输出流不用手动close()关闭
//标准输出流不再指向控制台,指向“log”文件 PrintStream printStream = new PrintStream(new FileOutputStream("log")); //修改输出方向,将输出方向修改到“log”文件 System.setOut(printStream); System.out.println("hello world); System.out.println("hello kitty); System.out.println("hello zhangsan);
对象专属流
ObjectInputStream
ObjectOutputStream
序列化流
序列化:Serialize,java对象存储到文件中。将java对象的状态保存下来的过程。
反序列化:DeSerialize,将硬盘上的数据重新恢复到内存当中,恢复成java对象。
参与序列化和反序列化的对象,必须实现Serializable接口,不然会出异常。
public class Student implements Serialized{
private int no;
private String name;
...
}
注意:通过看源码发现,Serializable接口只是一个标志接口,这个接口当中什么代码都没有。它起到标识的作用,java虚拟机看到这个类实现了这个接口,会对这个类进行特殊待遇。
Serializable这个标志接口是给java虚拟机参考的,java虚拟机看到这个接口后,会为该类自动生成一个序列化版本号。
建议将序列化版本号手动写出来,不建议自动生成:
private static final long serialVersionUID = 1L;
java虚拟机识别
序列化版本号的作用
java虚拟机识别一个类的时候,先通过类名,如果类名一致,再通过序列化版本号。不同的人编写了同一个类,但这两个类确实不是同一个类,这个时候序列化版本就起到作用了。
自动生成序列化版本号的缺陷:
一个类,序列化之后,如果再修改代码,会重新编译,这个类的序列化版本号也会发生相应的改变。这时,如果再去反序列化,就会出现异常。
结论:
凡是一个类实现了Serializable接口,建议给该类提供一个固定不变的序列化版本号。这样,即使这个类的代码发现了修改,但是版本号不变,java虚拟机会认为是同一个类。
序列化
Student s = new Student(1111, "zhangsan");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("students"));
//序列化对象
oos.writeObject(s);
oos.flush();
oos.close();
反序列化
Student s = new Student(1111, "zhangsan");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("students"));
//反序列化,读
Object obj = ois.readObject();
sout(obj);
ois.close();
一次序列化多个对象
List<User> userList = new ArrayList<>();
userList.add(new User(1, "zhangsan"));
userList.add(new User(2, "lisi"));
userList.add(new User(3, "wangwu"));
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("users"));
oos.writeObject(userList);
oos.flush();
oos.close();
反序列化集合
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("users"));
List<User> userList = (List<User>)ois.readObject();
for(User user : userList){
sout(user);
}
ois.close();
transient关键字,表示游离的,不参与序列化操作!
private int no;
private transient String name; //name不参与序列化
IO和Properties联合使用
以后经常改变的数据,可以单独写到一个文件中,使用程序动态读取。将来只需要修改这个文件的内容,java代码不需要改动,不需要重新编译,服务器也不需要重启,就可以拿到动态的信息。
FileReader reader = new FileReader("chapter/userinfo");
Properties pro = new Properties();
//调用Properties对象的load方法将文件中的数据加载到map集合中
pro.load(reader);
//通过key,获取value
String username = pro.getProperty("username");
String password = pro.getProperty("password");
2.3 File类
和四大家族没有关系,所以File类不能完成文件的读和写。
File对象代表文件和目录路径名的抽象表示形式,一个File对象有可能对应的是目录,有可能是文件。
我们需要掌握File类中常用的方法
- exists():判断文件是否存在
- createNewFile():以文件形式新建
- mkdir():以目录的形式新建
- mkdirs():以目录的形式新建多重目录
- getParent():获取文件的父路径
- getAbsolutePath():获取绝对路径
- getName():获取文件名
- isDirectory():判断是不是一个目录
- isFile():判断是不是一个文件
- lastModified():返回文件最后一次的修改时间
- length():返回文件大小,字节数。
File f1 = new File("D:\\file");
sout(f1.exists());
if(!f1.exists()) {
f1.createNewFile();
}
if(!f1.exists()) {
f1.mkdir();
}
File f2 = new File("D:/a/b/c/d");
if(!f2.exists()) {
f2.mkdirs();
}
- listFiles():获取当前目录下所有的子文件
File f = new File("D:\\course"); File[] files = f.listFiles(); for(File file : files) { sout(file.getAbsolutePath()); sout(file.getName()); }
拷贝目录
public class CopyAll {
public static void main(String[] args) {
// 拷贝源
File srcFile = new File("D:\\course\\02-JavaSE\\document");
// 拷贝目标
File destFile = new File("C:\\a\\b\\c");
// 调用方法拷贝
copyDir(srcFile, destFile);
}
/**
* 拷贝目录
* @param srcFile 拷贝源
* @param destFile 拷贝目标
*/
private static void copyDir(File srcFile, File destFile) {
if(srcFile.isFile()) {
// srcFile如果是一个文件的话,递归结束。
// 是文件的时候需要拷贝。
// ....一边读一边写。
FileInputStream in = null;
FileOutputStream out = null;
try {
// 读这个文件
// D:\course\02-JavaSE\document\JavaSE进阶讲义\JavaSE进阶-01-面向对象.pdf
in = new FileInputStream(srcFile);
// 写到这个文件中
// C:\course\02-JavaSE\document\JavaSE进阶讲义\JavaSE进阶-01-面向对象.pdf
String path = (destFile.getAbsolutePath().endsWith("\\") ? destFile.getAbsolutePath() : destFile.getAbsolutePath() + "\\") + srcFile.getAbsolutePath().substring(3);
out = new FileOutputStream(path);
// 一边读一边写
byte[] bytes = new byte[1024 * 1024]; // 一次复制1MB
int readCount = 0;
while((readCount = in.read(bytes)) != -1){
out.write(bytes, 0, readCount);
}
out.flush();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return;
}
// 获取源下面的子目录
File[] files = srcFile.listFiles();
for(File file : files){
// 获取所有文件的(包括目录和文件)绝对路径
//System.out.println(file.getAbsolutePath());
if(file.isDirectory()){
// 新建对应的目录
//System.out.println(file.getAbsolutePath());
//D:\course\02-JavaSE\document\JavaSE进阶讲义 源目录
//C:\course\02-JavaSE\document\JavaSE进阶讲义 目标目录
String srcDir = file.getAbsolutePath();
String destDir = (destFile.getAbsolutePath().endsWith("\\") ? destFile.getAbsolutePath() : destFile.getAbsolutePath() + "\\") + srcDir.substring(3);
File newFile = new File(destDir);
if(!newFile.exists()){
newFile.mkdirs();
}
}
// 递归调用
copyDir(file, destFile);
}
}
}
3. 多线程
3.1 概述
进程是一个应用程序(1个进程是一个软件),线程是一个进程中的执行场景/执行单元。一个进程可以启动多个线程。
对于java程序来说,启动java程序会先启动JVM,JVM就是一个进程。JVM再启动一个主线程调用main方法,同时再启动一个垃圾回收线程负责看护,回收垃圾。最起码,现在的java程序中有两个线程并发,一个是垃圾回收线程,一个是执行main方法的主线程。
进程和线程的关系
打个比方:进程可以看做是现实生活当中的公司,线程可以看做是公司当中的某个员工。
进程A和进程B的内存(资源)独立不共享。
线程A和线程B,堆内存和方法区内存共享,但栈内存独立。一个线程一个栈。
假设启动10个线程,会有10个栈空间,每个栈和每个栈之间,互不干扰,各自执行各自的,这就是多线程并发。
多线程并发可以提高效率。
所以,使用了多线程机制,main方法结束,可能程序也不会结束。main方法结束只是主线程结束了,主栈空了,其它的栈(线程)可能还在压栈弹栈。
分析一个问题,对于单核的CPU来说,可以做到真正的多线程并发吗?
对于4核CPU表示同一个时间点上,可以真正的有4个进程并发执行。但是单核CPU不行。真正的多线程并发,指的是每个线程之间互不影响。
而单核CPU只有一个大脑,不能做到真正的多线程并发,但是可以做到给人一种“多线程并发”的感觉。对于单核的CPU来说,在某一个时间点上实际只能处理一件事情,但是由于CPU的处理速度极快,多个线程之间频繁切换执行,给人的感觉就是多个事情同时在做。
3.2 实现线程的两种方式
-
第一种方式
编写一个类,直接继承java.lang.Thread,重写run方法
创建对象,启动线程。public static void main(String[] args) { //创建一个分支线程对象 MyThread myThread = new MyThread(); //启动线程 myThread.start(); //myThread.run(); for(int i = 0; i < 1000; i++) { sout("主线程--->" + i); } } class MyThread extends Thread{ @Override public void run() { for(int i = 0; i < 1000; i++) { sout("分支线程--->" + i); } } }
start()方法的作用是:启动一个分支线程,在JVN中开辟一个新的栈空间,只要新的栈空间开出来,start()方法就结束了,线程启动成功。
启动成功的线程会自动调用run方法,并且run方法在分支栈的栈底部(压栈)。
run方法在分支栈的栈底部,main方法在主栈的栈底部。run和main是平级的。如果不调用start方法开启线程,直接调用run方法,不会分配新的分支栈,这还是单线程。直接调用run方法,相当于还是一个普通的java类。
直接调用run方法的内存图:
调用start方法
-
第二种方式
public static void main(String[] args) { //创建一个可运行的对象 MyRunnable r = new MyRunnale(); //将可运行的对象封装成一个线程对象 Thread t = new Thread(r); // Thread t = new Thread(new MyRunnale()); 合并代码 //启动线程 t.start(); for(int i = 0; i < 100; i++) { sout("主线程--->" + i); } } class MyRunnable implements Runnable{ @Override public void run() { for(int i = 0; i < 100; i++) { sout("分支线程--->" + i); } } }
第二种方式实现接口比较常用,因为第一个类实现了接口,它还可以去继承其他的类,更灵活。
匿名内部类实现:
public static void main(String[] args) { Thread t = new Thread(new Runnable() { @Override public void run() { for(int i = 0; i < 100; i++) { sout("分支线程--->" + i); } } }); //启动线程 t.start(); for(int i = 0; i < 100; i++) { sout("主线程--->" + i); } }
3.3 线程的生命周期
新建状态、就绪状态、运行状态、阻塞状态、死亡状态。
3.4 相关方法
-
获取线程的名字
String name = 线程对象.getName();
设置对象的名字:线程对象.setName("线程名字");
如果不设置线程对象的名字,默认为:Thread-0、Thread-1 … -
获取当前对象
Thread t = Thread.currentThread();
返回值t就是当前线程
currentThread方法在哪里出现,当前线程就是哪个线程。Thread currentThread = Thread.currentThread(); sout(currentThread.getName());
-
让当前线程进入睡眠
static void sleep(long millis)
静态方法:Thread.sleep(1000),参数是毫秒
作用:让当前线程进入“阻塞状态”,放弃占有CPU时间片,让给其他线程使用。
注意:不管谁调用的sleep方法,sleep方法在哪个线程,哪个线程睡眠,跟谁调用它没有关系。如果想唤醒正在睡眠的线程,可以用
线程对象.interrupt()
方法。
注意:不是中断线程的执行,是终止线程的睡眠。public static void main(String[] args) { Thread t = new Thread(new MyRunnable()); t.start(); try{ Thread.sleep(1000*5); } catch (InterruptedException e) { e.printStackTrace(); } t.interrupt(); //干扰 } class MyRunnable implements Runnable { @Override public void run() { sout(Thread.currentThread().getName() + "---> begin"); try{ Thread.sleep(1000 * 60 * 60 * 24 * 365); } catch(InterruptedException e) { //e.printStackTrace(); } sout(Thread.currentThread().getName() + "---> end"); } }
中断t线程的睡眠,这种中断睡眠的方式是依靠了java的异常处理机制。就是执行了interrupt()方法,会抛出异常使睡眠结束。
注意:run() 当中的异常不能throws,只能 try catch。因为run()方法在父类中没有抛出任何异常,子类不能比父类抛出更多的异常。
-
终止一个线程
强行终止:线程对象.stop()
方法。
这种方式存在很大的缺点:容易丢失数据,因为是直接杀死了线程,可能线程没有保存的数据将会丢失。不建议使用。合理的终止一个线程的执行:使用标记
public static void main(String[] args) { MyRunable r = new MyRunable(); Thread t = new Thread(r); t.setName("t"); t.start(); try { Thread.sleep(5000); } catch (InterruptedException e) { } //5秒后,终止这个线程 r.run = false; //你想什么时候终止t的执行,将标记改成false,就结束了 } class MyRunable implements Runable { boolean run = true; @Override public void run() { for(int i = 0; i < 10; i++) { if(run) { ... } else { //可以在return前,保存数据 return; } } } }
3.5 线程调度
常见的线程调度
- 抢占式调度模型
哪个线程的优先级比较高,抢到的CPU时间片的概率就高一些/多一些。
java采用的就是抢占式调度模型。 - 均分式调度模型
平均分配CPU时间片。每个线程占有的CPU时间片长度一样。
平均分配,一切平等。
有一些编程语言,线程调度模型采用的是这种方式。 - 线程调度相关方法
- 实例方法
void setPriority(int newPriority)
:设置线程的优先级。
int getPriority()
:获取线程优先级
线程优先级最低是1(MIN_PRIORITY),最高是10(MAX_PRIORITY),默认是5(NORM_PRIORITY)。
优先级比较高的获取CPU时间片可能会多一些。(大概率多一些)
void join()
:合并线程t.join(); //t合并到当前线程中,当前线程受阻塞,t线程执行完,再执行当前线程
- 静态方法
static void yield()
:让位
暂停当前正在执行的线程对象,并执行其他线程。
yield()方法的执行会让当前线程从“运行状态”回到“就绪状态”。
- 实例方法
3.6 线程安全(重点)
关于多线程并发环境下,数据的安全问题。
重要的是:我们要知道,我们编写的程序需要放到一个多线程的环境下运行,需要关注这些数据在多线程并发的环境下是否是安全的。
在多线程并发的环境下,满足3个条件之后,会存在线程安全问题:
条件1:多线程并发。
条件2:有共享数据
条件3:共享数据有修改的行为。
注意,java中有三大变量:实例变量、静态变量、局部变量。
实例变量在堆中,
静态变量在方法区,
局部变量在栈中。
以上三个变量,局部变量永远不会存在线程安全问题,因为局部变量不共享数据(一个线程一个栈)。堆和方法区只有1个,都是多线程共享的,所以存在线程安全问题。
解决线程安全问题:
用排队执行解决线程安全问题。这种机制被称为:线程同步。实际上就是线程不能并发了,线程必须排队执行。
线程同步就是线程排队了,线程排队了就会牺牲一部分效率。数据安全第一位,数据安全了,再去谈效率。
模拟两个线程对同一个账户取款
Account类
private String actno; //账号
private double balance; //余额
//省略构造方法,省略getter和setter方法
...
//取款方法
public void withdraw(double money) {
double before = this.getBalance(); //取款前的余额
double after = before - money; //取款后的余额
//如果线程t1执行到这,但还没执行下面这行代码,此时t2线程也进入withdraw方法,就出问题了。
//模拟网络延迟,一定会出问题
try{
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.setBalance(after); //更新余额
}
AccountThread
public class AccountThread extends Thread {
//两个线程必须共享同一个账户对象
private Account act;
public AccountThread(Account act) {
this.act = act;
}
public void run() {
double money = 5000;
act.withdraw(money);
sout(Thread.currentThread().getName() + "对" + act.getActno() + "取款" + money + "成功,余额" + act.getBalance());
}
}
主方法,创建两个对象
public class Test{
public static void main(String[] args) {
//创建一个账户对象
Account act = new Account("act-001", 10000);
//创建两个线程
Thread t1 = new AccountThread(act);
Thread t2 = new AccountThread(act);
t1.setName("t1");
t2.setName("t2");
t1.start();
t2.start();
}
}
结果:
t1对act-001取款5000.0成功,余额5000.0
t2对act-001取款5000.0成功,余额5000.0
同步代码块synchronized
将上面的例子修改成线程安全的,加synchronized同步代码块。
以下几行代码必须是线程排队的,不能并发。一个线程执行结束,另一个线程才能进来。
public void withdraw(double money) {
synchronized (this){
double before = this.getBalance();
double after = before - money;
try{
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.setBalance(after);
}
}
这里至关重要的是,synchronized括号中传递的数据,这个数据必须是多线程共享的数据,才能达到多线程排队。
括号中写什么,是要看你想让哪些线程同步。
假设t1、t2、t3、t4、t5,有5个线程,你想让t1、t2、t3排队,那括号中要写t1、t2、t3共享的对象,而这个对象对于t4、t5来说不是共享的。
在java语言中,任何一个对象都有“一把锁”,这把锁其实是一个标记。
同步代码块执行原理:
- 假设t1和t2线程并发,开始执行以下代码的时候,会有先有后。
- 假设t1先执行了,遇到了synchronized,这个时候自动找“括号中共享对象”的对象锁,找到之后,并占有这把锁,然后执行同步代码块中的程序,在程序执行过程中一直都是占有这把锁的。直到同步代码块结束,这把锁才会释放。
- 如果t1已经占有这把锁,此时t2也遇到synchronized关键字,也会去占有括号中共享对象的这把锁,结果这把锁已经被t1占有,t2只能在同步代码块外面等待t1的结束。直到t1把同步代码块执行结束了,t1归还这把锁,此时t2终于等到了这把锁,然后t2才能占有这把锁,进入同步代码块执行程序。
进入锁池,可以理解成一种阻塞状态。
对共享数据的理解:
this指的是对象本身,这个程序中是指Account对象。
synchronized (this){
...
}
创建的两个线程,使用的同一个对象,那么就算是共享数据。
Account act = new Account("act-001", 10000);
Thread t1 = new AccountThread(act);
Thread t2 = new AccountThread(act);
Account act2 = new Account("act-002", 10000);
Thread t3 = new AccountThread(act2);
而下面又创建了一个Account对象,这个对象创建出来的线程,跟上面数据是不共享的,这个Account对象的this是另一个。
总之,想清楚synchronized括号中的对象,是不是想要共享的。
写成abc字符串,创建出来的线程就都是同步的了。
synchronized ("abc"){
...
}
在实例方法上使用synchronized
synchronized出现在实例方法上,一定锁的是this(共享对象是this),不能是其它对象了。
缺点:不灵活;整个方法体都需要同步,可能会无故扩大同步的范围,导致程序的执行效率降低。
优点:代码节俭。
如果共享的对象就是this,并且需要同步的代码块时整个方法体,建议使用这种方式。
public synchronized void withdraw(double money) {
double before = this.getBalance();
double after = before - money;
try{
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.setBalance(after);
}
synchronized的第三种写法
在静态方法上使用synchronized,表示类锁,类锁永远只有1把。
对象锁:1个对象1把锁,100个对象100把锁。
类锁:100个对象,也可能只是1把类锁。
public static void main(String[] args) {
MyClass mc1 = new MyClass();
MyClass mc2 = new MyClass();
Thread t1 = new MyThread(mc1);
Thread t2 = new MyThread(mc2);
...
}
class MyClass {
public synchronized static void doSome() {
sout("doSome begin");
try{
Thread.sleep(1000*10);
} catch (InterruptedException e) {
e.printStackTrace();
}
sout("doSome over");
}
public synchronized static void doOther() {
sout("doOther begin");
sout("doOther end");
}
}
t2需要等待t1执行完才执行,因为类锁永远只有1把,即使创建了两个对象,用的也是同一个锁。
死锁现象
死锁很难调试,不会出异常也不报错。要求会写死锁代码,只有回写了,才会在以后的开发中注意这个事儿。
public class DeadLock {
public static void main(String[] args) {
Object o1 = new Object();
Object o2= new Object();
Thread t1 = new MyThread(o1,o2);
Thread t2 = new MyThread(o1,o2);
t1.start();
t2.start();
}
}
class MyThread1 extends Thread {
Object o1;
Object o2;
public MyThread1(Object o1, Object o2) {
this.o1 = o1;
this.o2 = o2;
}
public void run() {
synchronized (o1) {
try{
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
}
}
}
}
class MyThread2 extends Thread {
Object o1;
Object o2;
public MyThread2(Object o1, Object o2) {
this.o1 = o1;
this.o2 = o2;
}
public void run() {
synchronized (o2) {
try{
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o1) {
}
}
}
}
所以,synchronized最好不要嵌套使用 ,一不小心,可能就会产生死锁。
在实际开发中,解决线程安全问题
不要一上来就选择线程同步synchronized。
- 第一种方案:尽量使用局部变量代替“实例变量和静态变量”。
- 第二种方案:如果必须是实例变量,那么可以考虑创建多个对象,这样变量的内存就不共享了。(1个线程1个对象,100个线程对应100个对象,对象不共享,就没有数据安全问题了)
- 第三种方案:如果不能使用局部变量,对象也不能创建多个,这个时候就只能选择synchronized了,线程同步机制。
3.7 守护线程和定时器
java语言中线程分为两大类:
用户线程
守护线程(后台线程)
其中具有代表性的就是:垃圾回收线程(守护线程)。
守护线程的特点:
一般守护线程是一个死循环,所有的用户线程只要结束,守护线程自动结束。
注意:主线程main方法是一个用户线程。
例如:每天00:00的时候系统数据自动备份,这个需要使用到定时器,并且我们可以将定时器设置为守护线程,一直在那里看着,每到00:00的时候就备份一次。当所有的用户线程结束了,守护线程自动退出,没有必要进行数据备份了。
实现:在启动线程之前,将线程设置为守护线程。
public static void main(String[] args) {
Thread t = new BakDataThread();
t.setName("备份数据的线程");
t.setDaemon(true); //将主线程设置为守护线程
t.start();
...
}
定时器的作用:间隔特定的时间,执行特定的程序。
如:每周要进行银行账户的总账操作;每天要进行数据的备份操作。
在实际开发中,每隔多久执行一段特定的程序,这种需求是很常见的,在java中其实可以采用多种方式实现:
- sleep方法,设置睡眠时间。这种方式是最原始的定时器。(不好)
- java.util.Timer,java类库中的定时器。
- Spring提供的SpringTask框架。目前开发中使用较多。
实现定时器,用Timer对象中的schedule方法
public static void main(String[] args) {
Timer timer = new Timer();
//Timer timer = new Timer(true); 加一个参数,表示守护线程的方式
//timer.schedule(定时任务, 第一次执行时间, 间隔多久执行一次)
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date firstTime = sdf.parse("2022-03-09 14:50:00");
timer.schedule(new LogTimerTask(), firstTime, 1000*10);
}
class LogTimerTask extends TimerTask {
@Override
public void run() {
//编写任务
}
}
3.8 实现线程的第三种方式
实现Callable接口(JDK8新特性)。
这种方式实现的线程可以获取线程的返回值。之前讲解的两种方式无法获取线程返回值,因为run方法返回void。
思考:系统委派一个线程去执行一个任务,该线程执行完之后,可能会有一个执行结果,我们怎么能拿到这个执行结果呢?
使用第三种方式:实现Callable接口。
public static void main(String[] args) throws Exception{
//创建一个“未来任务类”对象
//参数非常重要,需要给一个Callable接口实现类对象
FutureTask task = new FutureTask(new Callable() {
@Override
public Object call() throws Exception { //call方法相当于run方法,只不过call方法有返回值
//线程执行一个任务,执行之后可能会有一个执行结果
sout("call method begin");
Thread.sleep(1000*10);
sout("call method end");
int a = 100;
int b = 100;
return a+b;
}
});
Thread t = new Thread(task);
t.start();
//用get方法获取t线程的返回结果
Object obj = task.get(); //get方法执行会导致“当前线程阻塞”
//main方法这里的程序要想执行,必须等待get()方法的结束
//而get方法可能需要很久,因为get方法是为了拿另一个线程的执行结果,需要时间
sout("线程执行结果:" + obj);
sout("hello world!");
}
优点:可以获取到线程的执行结果。
缺点:效率比较低,在获取t线程执行结果的时候,当前线程受阻塞,效率较低。
3.9 生产者消费者模型
-
wait和notify方法
注意:wait和notify方法不是线程对象的方法,是java中任何一个对象都有的方法,因为这两个方法是Object类中自带的。wait和notify不是通过线程对象调用。wait方法表示:让正在对象上活动的线程进入等待状态,无期限等待,直到被唤醒为止。
notify方法表示:对象调用wait方法进入等待状态,直到调用notify方法才唤醒。生产者和消费者模型是为了专门解决某个特定需求的。
wait方法会让正在对象上活动的当前线程进入等待状态,并且释放之前对象占有的锁。
notify方法只会通知,不会释放占有的锁。实现生产者消费者模型:
仓库采用list集合,假设list集合中只能存储1个元素,1个元素就表示仓库满了。
如果list集合中元素个数是0,就表示仓库空了。
保证list集合中永远都是最多存储1个元素。
做到:生产1个,消费一个。public static void main(String[] args) { List list1 = new ArrayList(); Thread t1 = new Thread(new Producer(list)); Thread t2 = new Thread(new Consumer(list)); t1.setName("生产者线程"); t2.setName("消费者线程"); t1.start(); t2.start(); } class Producer implements Runnable { private List list; public Produces(List list) { this.list = list; } @Override public void run() { while(true) { //给仓库对象list加锁 synchronized(list) { if(list.size() > 0) { try{ //线程进入等待状态,并释放Producer占有的锁 list.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } //程序能执行到这里说明仓库是空的,可以生产 Object obj = new Object(); list.add(obj); sout(Thread.currentThread().getName() + "--->" + obj); list.notify(); } } } } class Consumer implements Runnable { private List list; public Consumer(List list) { this.list = list; } @Override public void run() { //一直消费 while(true) { synchronized(list) { if(list.size() == 0) { try{ list.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } //程序能执行到这里说明仓库有数据,进行消费 Object obj = list.remove(0); sout(Thread.currentThread().getName() + "--->" + obj); //唤醒生产者生产 list.notify(); } } } }
4. 遗漏的基础
4.1 static关键字
变量分类:
局部变量:在方法体当中声明的变量。
成员变量:在方法体外声明的变量。
成员变量又可分为:实例变量、静态变量
通过static关键字修饰的,都是类相关的,访问时用类名.
的方式访问。不需要对象参与即可访问。
没有通过static关键字修饰的,是对象相关的,访问时用对象.
的方式访问,需要先new对象。
静态变量在类加载时初始化,不需要new对象,静态变量的空间就开出来了。
静态变量存储在方法区。
静态变量如果没有赋值,默认为null。
什么时候使用static修饰?
1.一个属性,每次实例化都是相同的值,不建议定义为实例变量,浪费内存空间。建议定义成类级别的,用static修饰,在方法区中只保留一份,节省内存开销。
2.当属于同一个类的所有对象出现共享数据时,需要将存储这个共享数据的成员变量用static修饰。
注意:实例的,一定要用引用.
来访问;静态的可以用类名.
也可以用对象.
来访问。
静态的,使用对象.
访问,会转换成类名.
去访问。不建议使用对象.
访问,因为会给其他程序员造成困惑。
Chinese c1 = null;
sout(c1.country); //如果country是静态变量,可以访问。因为c1会被转换成类名。
//如果是country是实例变量,会出现空指针异常。
通过以上结论,我们可以得出,“空引用”访问“实例”相关的,都会出现空指针异常。
-
静态代码块
static静态代码块:类加载时执行,并且只执行一次。static { sout("A"); } static { sout("B"); } public static void main(String[] args) { sout("Hello World!"); } static { sout("C"); }
结果:
A
B
C
Hello World!静态代码块在类加载时执行,并且在main方法执行之前执行。一般按照自上而下的顺序执行。
-
静态方法不存在方法覆盖
方法覆盖只是针对于“实例方法”,“静态方法覆盖”没有意义。
方法覆盖需要和多态机制联合起来使用才有意义。
Animal a = new Cat(); //多态
a.doSome(); //doSome是静态方法,这句代码会被转换成Animal.doSome(),跟子类无关了
4.2 多态
-
多态概述
多种形态,多种状态,编译和运行有两个不同的状态。
编译器叫做静态绑定。
运行期叫做动态绑定。Animal a = new Cat(); a.move();
编译的时候,编译器发现a的类型是Animal,所以编译器会去Animal类中找move()方法。找到了,绑定,编译通过。但是运行的时候和底层堆内存当中的实际对象有关。真正执行的时候会自动调用“堆内存中真实对象”的相关方法。
-
向上转型和向下转型的概念
向上转型:子—>父,又被称为自动类型的转换:Animal a = new Cat();向下转型:父—>子,又被称为强制类型转换:Cat c = (Cat) a;
需要调用或执行子类对象中特有的方法,必须进行向下转型,才可以调用。
风险:容易出现ClassCastException(类型转换异常)。
所以,要使用instanceof运算符,在程序运行阶段动态的判断某个引用指向的对象是否为某一种类型。不管是向上转型还是向下转型,首先他们之间必须有继承关系,这样编译器就不会报错。
-
多态的实际应用
public class Pet { //吃的行为,这个方法可以不给具体的实现 public void eat() {} } public class Cat extends Pet { public void eat() { sout("猫吃鱼..."); } } public class Master { //编译的时候,编译器发现pet是Pet类,会去Pet类中找eat()方法,找到了,编译通过 //运行时,调用实际对象对应的eat方法 public void feed(Pet pet) { pet.eat(); } } public static void main(String[] args) { Master zhangsan = new Master(); Cat a = new Cat(); zhangsan.feed(a); //这里传递的a就是使用了多态,因为Master类中feed方法的参数是Pet对象,Pet pet = new Cat() }
分析:如果以后有新的需求,只需要添加一个新的类,然后再去测试就行,不用修改其他类中的代码。
在实际开发中,用户的需求增加了,也就只用增加新的功能,不用修改原来的代码。
软件在扩展新需求过程当中,修改的越少越好。修改的越多,你的系统当前的稳定性就越差,未知的风险就越多。
这里涉及到一个软件的开发原则:OCP(开闭原则)。
软件开发有七大原则,这是最基本的一条。
开闭原则:对扩展开放,对修改关闭。
4.3 抽象类和接口
-
抽象类
类和类之间具有共同特征,将这些共同特征提取出来,形成的就是抽象类。
抽象类无法实例化,无法创建对象,所以抽象类是用来被子类继承的。
抽象类也属于引用数据类型。
abstract和final不能联合使用,这两个关键字是对立的。
抽象类的子类可以是抽象类。
抽象类虽然无法实例化,但是抽象类有构造方法,这个构造方法是供子类使用的。抽象方法:抽象方法表示没有实现的方法,没有方法体的方法。
抽象类不一定有抽象方法,抽象方法必须出现在抽象类中。
一个非抽象类继承抽象类,必须将抽象类中的方法实现了。 -
接口
接口是一种“引用数据类型”。
接口是完全抽象的。
接口支持多继承。
接口中只有常量和抽象方法。
接口中所有的元素都是public修饰的。
接口中抽象方法的public abstract可以省略。
接口中常量的public static final可以省略。
接口中方法不能有方法体。
一个非抽象类,实现接口的时候,必须将接口中所有方法加以实现。
一个类可以实现多个接口。
extends和implement可以共存,extends在前,implement在后。
使用接口,写代码的时候,可以使用多态。抽象类和接口的区别:
抽象类是半抽象的;接口是完全抽象的。
抽象类没有构造方法;接口中没有构造方法。
接口和接口之间支持多继承;类和类之间只能单继承。
一个类可以同时实现多个接口;一个类只能继承一个抽象类。
接口中只允许出现常量和抽象方法。
接口一般都是对“行为”的抽象。
更多推荐
JavaSE重点之集合、IO、多线程
发布评论