JavaRush/Java блог/Java Developer/Устройство вещественных чисел
Автор
Александр Выпирайленко
Java-разработчик в Toshiba Global Commerce Solutions

Устройство вещественных чисел

Статья из группы Java Developer
участников
Привет! В сегодняшней лекции расскажем о числах в Java, а конкретно — о вещественных числах. Устройство вещественных чисел - 1Без паники! :) Никаких математических сложностей в лекции не будет. Будем говорить о вещественных числах исключительно с нашей, «программистской» точки зрения. Итак, что же такое «вещественные числа»? Вещественные числа — это числа, у которых есть дробная часть (она может быть нулевой). Они могут быть положительными или отрицательными. Вот несколько примеров: 15 56.22 0.0 1242342343445246 -232336.11 Как же устроено вещественное число? Достаточно просто: оно состоит из целой части, дробной части и знака. У положительных чисел знак обычно не указывают явно, а у отрицательных указывают. Ранее мы подробно разобрали, какие операции над числами можно совершать в Java. Среди них было много стандартных математических операций — сложение, вычитание и т. д. Было и кое-что новое для тебя: например, остаток от деления. Но как именно устроена работа с числами внутри компьютера? В каком виде они хранятся в памяти?

Хранение вещественных чисел в памяти

Думаю, для тебя не станет открытием, что числа бывают большими и маленькими :) Их можно сравнивать друг с другом. Например, число 100 меньше числа 423324. Влияет ли это на работу компьютера и нашей программы? На самом деле — да. Каждое число представлено в Java определенным диапазоном значений:
ТипРазмер в памяти (бит)Диапазон значений
byte8 битот -128 до 127
short16 битот -32768 до 32767
char16 битбеззнаковое целое число, которое преставляет собой символ UTF-16 (буквы и цифры)
int32 битаот -2147483648 до 2147483647
long64 битаот -9223372036854775808 до 9223372036854775807
float32 битаот 2-149 до (2-2-23)*2127
double64 битаот 2-1074 до (2-2-52)*21023
Сегодня поговорим именно о последних двух типах — float и double. Оба выполняют одну и ту же задачу — представляют дробные числа. Их еще очень часто называют «числа с плавающей точкой». Запомни этот термин на будущее :) Например, число 2.3333 или 134.1212121212. Довольно странно. Ведь получается, нет никакой разницы между этими двумя типами, раз они выполняют одну и ту же задачу? Но разница есть. Обрати внимание на столбец «размер в памяти» в таблице выше. Все числа (да и не только числа — вообще вся информация) хранится в памяти компьютера в виде битов. Бит — это самая маленькая единица измерения информации. Она довольно проста. Любой бит равен или 0, или 1. Да и само слово «bit» происходит от английского «binary digit» — двоичное число. Думаю, ты наверняка слышал о существовании двоичной системы счисления в математике. Любое привычное нам десятичное число можно представить в виде набора единиц и нулей. Например, число 584.32 в двоичной системе будет выглядеть так: 100100100001010001111. Каждые единица и ноль в этом числе являются отдельным битом. Теперь тебе должна быть более понятна разница между типами данных. Например, если мы создаем число типа float, в нашем распоряжении есть всего 32 бита. При создании числа float именно столько места будет выделено для него в памяти компьютера. Если же мы хотим создать число 123456789.65656565656565, в двоичном виде оно будет выглядеть так: 11101011011110011010001010110101000000. Оно состоит из 38 единиц и нулей, то есть для его хранения в памяти нужно 38 бит. В тип float это число просто не «влезет»! Поэтому число 123456789 можно представить в виде типа double. Для его хранения выделяется целых 64 бита: это нам подходит! Разумеется, и диапазон значений тоже будет подходящим. Для удобства ты можешь представлять число как маленький ящик с ячейками. Если ячеек хватает для хранения каждого бита, значит, тип данных выбран правильно :) Устройство вещественных чисел - 2Разумеется, разное количество выделяемой памяти влияет и на само число. Обрати внимание, что у типов float и double отличается диапазон значений. Что это означает на практике? Число double может выразить большую точность, чем число float. У 32-битных чисел с плавающей точкой (в Java это как раз тип float) точность составляет примерно 24 бита, то есть около 7 знаков после запятой. А у 64-битных чисел (в Java это тип double) — точность примерно 53 бита, то есть примерно 16 знаков после запятой. Вот пример, который хорошо демонстрирует эту разницу:
public class Main {

   public static void main(String[] args)  {

       float f = 0.0f;
       for (int i=1; i <= 7; i++) {
           f += 0.1111111111111111;
       }

       System.out.println(f);
   }
}
Что мы должны получить здесь в качестве результата? Казалось бы, все довольно просто. У нас есть число 0.0, и мы 7 раз подряд прибавляем к нему 0.1111111111111111. В итоге должно получиться 0.7777777777777777. Но мы создали число float. Его размер ограничен 32 битами и, как мы сказали ранее, он способен отобразить число примерно до 7 знака после запятой. Поэтому в итоге результат, который мы получим в консоли, будет отличаться от того, что мы ожидали:

0.7777778
Число как будто было «обрезано». Ты уже знаешь как хранятся данные в памяти — в виде битов, поэтому тебя не должно это удивлять. Понятно, почему это произошло: результат 0.7777777777777777 просто не влез в выделенные нам 32 бита, поэтому и был обрезан так, чтобы поместиться в переменную типа float :) Мы можем изменить тип переменной на double в нашем примере, и тогда итоговый результат не будет обрезан:
public class Main {

   public static void main(String[] args)  {

       double f = 0.0;
       for (int i=1; i <= 7; i++) {
           f += 0.1111111111111111;
       }

       System.out.println(f);
   }
}

0.7777777777777779
Здесь уже 16 знаков после запятой, результат «уместился» в 64 бита. Кстати, возможно ты заметил, что в обоих случаях результаты получились не совсем корректными? Подсчет был произведен с небольшими ошибками. О причинах этого мы поговорим ниже :) Теперь скажем пару слов о том, как можно сравнить числа между собой.

Сравнение вещественных чисел

Мы частично уже затрагивали этот вопрос в прошлой лекции, когда говорили об операциях сравнения. Такие операции как >, <, >=, <= повторно разбирать мы не будем. Вместо этого рассмотрим более интересный пример:
public class Main {

   public static void main(String[] args)  {

       double f = 0.0;
       for (int i=1; i <= 10; i++) {
           f += 0.1;
       }

       System.out.println(f);
   }
}
Как ты думаешь, какое число будет выведено на экран? Логичным ответом был бы ответ: число 1. Мы начинаем отсчет с числа 0.0 и последовательно прибавляем к нему 0.1 десять раз подряд. Вроде все правильно, должна получиться единица. Попробуй запустить этот код, и ответ сильно тебя удивит :) Вывод в консоль:

0.9999999999999999
Но почему в таком простом примере возникла ошибка? О_о Тут бы даже пятиклассник с легкостью верно ответил, но программа на Java выдала неточный результат. «Неточный» тут более подходящее слово, чем «неправильный». Мы все-таки получили очень близкое к единице число, а не просто какое-то рандомное значение :) Оно отличается от правильного буквально на миллиметр. Но почему? Возможно, это просто разовая ошибка. Может, комп заглючил? Попробуем написать другой пример.
public class Main {

   public static void main(String[] args)  {

       //прибавляем к нулю 0.1 одиннадцать раз подряд
       double f1 = 0.0;
       for (int i = 1; i <= 11; i++) {
           f1 += .1;
       }

       //Умножаем 0.1 на 11
       double f2 = 0.1 * 11;

       //должно получиться одно и то же - 1.1 в обоих случаях
       System.out.println("f1 = " + f1);
       System.out.println("f2 = " + f2);

       // Проверим!
       if (f1 == f2)
           System.out.println("f1 и f2 равны!");
       else
           System.out.println("f1 и f2 не равны!");
   }
}
Вывод в консоль:

f1 = 1.0999999999999999
f2 = 1.1
f1 и f2 не равны!
Так, дело явно не в глюках компа :) Что происходит? Подобные ошибки связаны с тем, как числа представлены в двоичном виде в памяти компьютера. Дело в том, что в двоичной системе невозможно точно представить число 0,1. В десятичной системе, кстати, тоже есть подобная проблема: в ней нельзя правильно представить дроби (и вместо ⅓ мы получим 0.33333333333333…, что тоже не совсем правильный результат). Казалось бы, мелочь: при таких подсчетах разница может быть в одну стотысячную часть (0,00001) или даже меньше. Но что, если от этого сравнения будет зависеть весь результат работы твоей Очень Серьезной Программы?
if (f1 == f2)
   System.out.println("Ракета летит в космос");
else
   System.out.println("Запуск отменяется, все расходятся по домам");
Мы явно ожидали, что два числа будут равны, но из-за особенностей внутреннего устройства памяти мы отменили запуск ракеты. Устройство вещественных чисел - 3Раз так, нам нужно определиться, как же все-таки сравнить два числа с плавающей точкой, чтобы результат сравнения был более...эммм...предсказуемым. Итак, правило №1 при сравнении вещественных чисел мы уже усвоили: никогда не используй == при сравнении чисел с плавающей точкой. Ок, плохих примеров, думаю, достаточно :) Давай рассмотрим хороший пример!
public class Main {

   public static void main(String[] args)  {

       final double threshold = 0.0001;

       //прибавляем к нулю 0.1 одиннадцать раз подряд
       double f1 = .0;
       for (int i = 1; i <= 11; i++) {
           f1 += .1;
       }

       //Умножаем 0.1 на 11
       double f2 = .1 * 11;

       System.out.println("f1 = " + f1);
       System.out.println("f2 = " + f2);

       if (Math.abs(f1 - f2) < threshold)
           System.out.println("f1 и f2 равны");
       else
           System.out.println("f1 и f2 не равны");
   }
}
Здесь мы по сути делаем то же самое, но меняем способ сравнения чисел. У нас есть специальное «пороговое» число — 0.0001, одна десятитысячная. Оно может быть и другим. Это зависит от того, насколько точное сравнение тебе нужно в конкретном случае. Можно сделать его и больше, и меньше. С помощью метода Math.abs() мы получаем модуль числа. Модуль — это значение числа независимо от знака. Например, у чисел -5 и 5 модуль будет одинаковым и равен 5. Мы вычитаем второе число из первого, и если полученный результат, независимо от знака, будет меньше того порога, который мы установили, значит наши числа равны. Во всяком случае, они равны до той степени точности, которую мы установили с помощью нашего «порогового числа», то есть как минимум они равны вплоть до одной десятитысячной. Такой способ сравнения избавит тебя от неожиданного поведения, которое мы увидели в случае с ==. Еще один хороший способ сравнения вещественных чисел — использовать специальный класс BigDecimal. Этот класс специально был создан для хранения очень больших чисел с дробной частью. В отличие от double и float, при использовании BigDecimal сложение, вычитание и прочие математические операции выполняются не с помощью операторов (+- и т.д.), а с помощью методов. Вот как это будет выглядеть в нашем случае:
import java.math.BigDecimal;

public class Main {

   public static void main(String[] args)  {

       /*Создаем два объекта BigDecimal - ноль и 0.1.
       Делаем то же самое что и раньше - прибавляем 0.1 к нулю 11 раз подряд
       В классе BigDecimal сложение осуществляется с помощью метода add()*/
       BigDecimal f1 = new BigDecimal(0.0);
       BigDecimal pointOne = new BigDecimal(0.1);
       for (int i = 1; i <= 11; i++) {
           f1 = f1.add(pointOne);
       }

       /*Здесь тоже ничего не изменилось: создаем два объекта BigDecimal
       и умножаем 0.1 на 11
       В классе BigDecimal умножение осуществляется с помощью метода multiply()*/
       BigDecimal f2 = new BigDecimal(0.1);
       BigDecimal eleven = new BigDecimal(11);
       f2 = f2.multiply(eleven);

       System.out.println("f1 = " + f1);
       System.out.println("f2 = " + f2);

       /*Еще одна особенность BigDecimal - объекты чисел нужно сравнивать между
       собой с помощью специального метода compareTo()*/
       if (f1.compareTo(f2) == 0)
           System.out.println("f1 и f2 равны");
       else
           System.out.println("f1 и f2 не равны");
   }
}
Какой же вывод в консоль мы получим?

f1 = 1.1000000000000000610622663543836097232997417449951171875
f2 = 1.1000000000000000610622663543836097232997417449951171875
f1 и f2 равны
Мы получили ровно тот результат, на который рассчитывали. И обрати внимание, насколько точными получились наши числа, и сколько знаков после запятой в них уместилось! Гораздо больше, чем во float и даже в double! Запомни класс BigDecimal на будущее, он тебе обязательно пригодится :) Фух! Лекция получилась немаленькая, но ты справился: молодец! :) Увидимся на следующем занятии, будущий программист!
Комментарии (214)
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны авторизоваться
Олег Галабурда
Уровень 10
12 марта, 19:55
Большое спасибо за доходчивое изложение тонких нюансов. Респект! Мне кажется, что в статье после слов: " А у 64-битных чисел (в Java это тип double) — точность примерно 53 бита, то есть примерно 16 знаков после запятой. Вот пример, который хорошо демонстрирует эту разницу: " В примере есть лишний напечатанный символ "f" перед точкой с запятой: float f = 0.0f;
kaidzualex
Уровень 17
Expert
16 марта, 12:02
Вовсе не лишний. Java по умолчанию считает все числа с точкой типом double. Для больших целых чисел типа long используется l после значения: long x = 1234567890l и для float используется f: float y = 12.3456f
kaidzualex
Уровень 17
Expert
16 марта, 12:02
Странно что об этом не написано в материале, но думаю самостоятельный поиск информации не отнимет много времени
Тимур
Уровень 3
4 марта, 19:14
В западной матиматической практике для дробей принято использовать точку (point), а в наших регионах запятую. Поэтому, не следует вводить в заблуждение народ, числа не с плавающей точкой, а с плавающей запятой. Даже в вашем предложении есть эта не состыковка: "У 32-битных чисел с плавающей точкой (в Java это как раз тип float) точность составляет примерно 24 бита, то есть около 7 знаков после запятой." А за стрью большое спасибо! :)
Grigoryvvv
Уровень 6
Expert
21 января, 06:23
21.01.24. 4 уровень
Тимур
Уровень 3
4 марта, 19:41
Хаха)) Та-да))
Павел Team Lead в Netflix
17 декабря 2023, 22:19
Спасибо! Всё очень наглядно и доходчиво. Кое что даже прояснилось.
Anton
Уровень 9
3 декабря 2023, 09:04
Отличная статья, а можете подсказать как высчитывается зависимость почему 24 бита это примерно 7 знаков, а 53 это 16 знаков после запятой, или это фиксированные значения?
Aleksey63
Уровень 35
15 ноября 2023, 06:12
Автор на примере 1/3 в десятичной записи показывает, где и как может потеряться точность. Именно этого примера мне не хватало, чтобы понять, почему не все дробные числа можно точно записать в двоичной (и другой) системе. Спасибо.
Kirill Cheremnykh
Уровень 22
15 ноября 2023, 04:06
Интересная статья, спасибо автору! Только не понял, как в двоичном коде записать дробные десятичные числа?
Иван #3374913
Уровень 15
11 ноября 2023, 17:39
Какое максимальное число можно записать в объект BigDecimal?
Java Speak
Уровень 11
30 ноября 2023, 22:41
Как и с BigInteger - условно бесконечное число. (ограниченное только фреймом кучи (heap) памяти, которую JVM попросит у ОС и получив к ней доступ создаст выделенную область памяти ограниченную представлением JVM (в среднем размер "кучи" в ней 256 Мб, иногда 512 Мб, - и его нужно настраивать внешними средствами вручную). Так вот - работая с BigDecimal / BigInteger - мы всегда будем работать с объектами в куче внутри которых будет происходить динамическое добавление памяти в зависимости от длины числа (в принципе в теории Вы можете потратить весь объем кучи и превысить его емкость "Heap overflow", но это на практике просто немыслимые гипотетические вероятности которые вряд ли кто-то в здравом уме будет проверять. Главное что нужно знать, это то что работая с объектами класса BigDecimal / BigInteger как и с AtomicInteger вы не будете ограничены стэком (который весит обычно 1-2 Мб = так как вы уже будете работать не с примитивными (фиксированными ) типами данных которые хранятся только в стеке и им же ограничены)
Тимур
Уровень 3
4 марта, 19:44
Спасибо!
madmax
Уровень 14
8 марта, 12:37
добрый, где можно про это почитать поподробнее? на собственном опыте вычислил что в линуксе на си стек ограничен 8мб было бы интересно почитать поподробнее как это происходит в джаве
Java Speak
Уровень 11
19 марта, 13:03
Я лично не лазил под капотом стека (жалко на это тратить время), чтобы проверить сколько там реально Мб, смотря в какой сборке и какой JDK, смотря на какой платформе, например на Linux ( смотря какая еще его версия ), или на Windows / MacOS . Но читал об этом на StackOverflow, Habr и в документации, что для разных версий и платформ существуют разные реализации JVM и выделенный объем (обычно на каждый поток выделяется 1 Мб, но иногда бывает и больше, до 2 мб = это потолок). Уверен что примерно также дело обстоит и для "Си" на разных платформах.
degreez
Уровень 10
26 октября 2023, 18:40
Написано интересно и понятным языком, спасибо!
Владимир
Уровень 6
11 июля 2023, 05:34
Интересно, а BigDecimal это число? Т.е. получим ли мы результат обычного умножения BigDecimal на целое/вещественное число или вылетит ошибка?
Sasha Rozanov
Уровень 32
9 августа 2023, 14:55
Только с помощью методов класса BigDecimal
Optimus
Уровень 23
29 сентября 2023, 17:17
Добавлю, потому что это объект класса BigDecimal, а не число.